feat: mobile application init

This commit is contained in:
2026-08-29 13:00:01 +02:00
parent 65aa165ec6
commit 5f0e846713
126 changed files with 6332 additions and 62 deletions
+3
View File
@@ -18,3 +18,6 @@ BETTER_AUTH_URL=http://localhost:3080
# Dashboard (only VITE_ variables are exposed to browser code)
VITE_PUBLIC_API_URL=http://localhost:3080
VITE_PUBLIC_URL=http://localhost:3001
# Mobile (only EXPO_PUBLIC_ variables are exposed to native client code)
EXPO_PUBLIC_API_URL=http://localhost:3080
+3 -2
View File
@@ -3,12 +3,13 @@
"@basango/db": "workspace:*",
"@basango/domain": "workspace:*",
"@basango/logger": "workspace:*",
"@better-auth/drizzle-adapter": "^1.7.1",
"@better-auth/drizzle-adapter": "1.7.2",
"@better-auth/expo": "1.7.2",
"@hono/node-server": "^1.19.6",
"@hono/trpc-server": "^0.4.0",
"@hono/zod-openapi": "^1.1.4",
"@trpc/server": "^11.7.1",
"better-auth": "^1.7.1",
"better-auth": "1.7.2",
"camelcase-keys": "^10.0.1",
"date-fns": "catalog:",
"hono": "^4.13.3",
+10 -2
View File
@@ -3,6 +3,7 @@ import { accounts, sessions, users, verifications } from "@basango/db/schema";
import { config, env } from "@basango/domain/config";
import { logger } from "@basango/logger";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { expo } from "@better-auth/expo";
import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";
import { v7 as uuidv7 } from "uuid";
@@ -15,6 +16,11 @@ const cookieDomain = env.BETTER_AUTH_COOKIE_DOMAIN?.trim();
const secret =
env.BETTER_AUTH_SECRET?.trim() ??
(isProduction ? undefined : "basango-local-better-auth-secret-change-me");
const mobileOrigins = [
"basango://",
"basango://*",
...(!isProduction ? ["exp://", "exp://**", "exp://192.168.*.*:*/**"] : []),
];
if (!secret) {
throw new Error("BETTER_AUTH_SECRET is required in production.");
@@ -47,7 +53,8 @@ export const auth = betterAuth({
},
}),
emailAndPassword: {
disableSignUp: true,
autoSignIn: true,
disableSignUp: false,
enabled: true,
maxPasswordLength: 72,
minPasswordLength: 8,
@@ -65,6 +72,7 @@ export const auth = betterAuth({
},
},
plugins: [
expo(),
admin({
adminRoles: ["admin"],
defaultRole: "user",
@@ -75,7 +83,7 @@ export const auth = betterAuth({
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
trustedOrigins: [...config.api.cors.origin],
trustedOrigins: [...config.api.cors.origin, ...mobileOrigins],
});
export type AuthSession = typeof auth.$Infer.Session;
+2
View File
@@ -3,6 +3,7 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { createTRPCRouter } from "#api/trpc/init";
import { articlesRouter } from "#api/trpc/routers/articles";
import { categoriesRouter } from "#api/trpc/routers/categories";
import { feedRouter } from "#api/trpc/routers/feed";
import { operationsRouter } from "#api/trpc/routers/operations";
import { reportsRouter } from "#api/trpc/routers/reports";
import { sourcesRouter } from "#api/trpc/routers/sources";
@@ -10,6 +11,7 @@ import { sourcesRouter } from "#api/trpc/routers/sources";
export const appRouter = createTRPCRouter({
articles: articlesRouter,
categories: categoriesRouter,
feed: feedRouter,
operations: operationsRouter,
reports: reportsRouter,
sources: sourcesRouter,
+136
View File
@@ -0,0 +1,136 @@
import {
addReaderArticleToBookmark,
createReaderBookmark,
createReaderComment,
deleteReaderBookmark,
deleteReaderComment,
followReaderSource,
getReaderArticleById,
getReaderArticles,
getReaderBookmarkArticles,
getReaderBookmarks,
getReaderCategories,
getReaderComments,
getReaderSourceById,
getReaderSources,
removeReaderArticleFromBookmark,
unfollowReaderSource,
updateReaderBookmark,
} from "@basango/db/queries";
import {
bookmarkArticleListSchema,
bookmarkArticleSchema,
bookmarkIdSchema,
bookmarkListSchema,
commentListSchema,
createBookmarkSchema,
createCommentSchema,
deleteCommentSchema,
readerArticleListSchema,
readerArticleSchema,
readerSourceListSchema,
readerSourceSchema,
updateBookmarkSchema,
} from "@basango/domain/models";
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
const feedArticlesRouter = createTRPCRouter({
get: protectedProcedure.input(readerArticleSchema).query(async ({ ctx, input }) => {
return getReaderArticleById(ctx.db, input.id);
}),
list: protectedProcedure.input(readerArticleListSchema).query(async ({ ctx, input }) => {
return getReaderArticles(ctx.db, input);
}),
});
const feedBookmarksRouter = createTRPCRouter({
addArticle: protectedProcedure.input(bookmarkArticleSchema).mutation(async ({ ctx, input }) => {
return addReaderArticleToBookmark(
ctx.db,
ctx.session.user.id,
input.bookmarkId,
input.articleId,
);
}),
create: protectedProcedure.input(createBookmarkSchema).mutation(async ({ ctx, input }) => {
return createReaderBookmark(ctx.db, ctx.session.user.id, input);
}),
delete: protectedProcedure.input(bookmarkIdSchema).mutation(async ({ ctx, input }) => {
return deleteReaderBookmark(ctx.db, ctx.session.user.id, input.id);
}),
list: protectedProcedure.input(bookmarkListSchema).query(async ({ ctx, input }) => {
return getReaderBookmarks(ctx.db, ctx.session.user.id, input);
}),
listArticles: protectedProcedure
.input(bookmarkArticleListSchema)
.query(async ({ ctx, input }) => {
return getReaderBookmarkArticles(ctx.db, ctx.session.user.id, input);
}),
removeArticle: protectedProcedure
.input(bookmarkArticleSchema)
.mutation(async ({ ctx, input }) => {
return removeReaderArticleFromBookmark(
ctx.db,
ctx.session.user.id,
input.bookmarkId,
input.articleId,
);
}),
update: protectedProcedure.input(updateBookmarkSchema).mutation(async ({ ctx, input }) => {
return updateReaderBookmark(ctx.db, ctx.session.user.id, input);
}),
});
const feedCategoriesRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return getReaderCategories(ctx.db);
}),
});
const feedCommentsRouter = createTRPCRouter({
create: protectedProcedure.input(createCommentSchema).mutation(async ({ ctx, input }) => {
return createReaderComment(ctx.db, ctx.session.user.id, input);
}),
delete: protectedProcedure.input(deleteCommentSchema).mutation(async ({ ctx, input }) => {
return deleteReaderComment(ctx.db, ctx.session.user.id, input.id);
}),
list: protectedProcedure.input(commentListSchema).query(async ({ ctx, input }) => {
return getReaderComments(ctx.db, input);
}),
});
const feedSourcesRouter = createTRPCRouter({
follow: protectedProcedure.input(readerSourceSchema).mutation(async ({ ctx, input }) => {
return followReaderSource(ctx.db, ctx.session.user.id, input.id);
}),
get: protectedProcedure.input(readerSourceSchema).query(async ({ ctx, input }) => {
return getReaderSourceById(ctx.db, ctx.session.user.id, input.id);
}),
list: protectedProcedure.input(readerSourceListSchema).query(async ({ ctx, input }) => {
return getReaderSources(ctx.db, ctx.session.user.id, input);
}),
unfollow: protectedProcedure.input(readerSourceSchema).mutation(async ({ ctx, input }) => {
return unfollowReaderSource(ctx.db, ctx.session.user.id, input.id);
}),
});
export const feedRouter = createTRPCRouter({
articles: feedArticlesRouter,
bookmarks: feedBookmarksRouter,
categories: feedCategoriesRouter,
comments: feedCommentsRouter,
sources: feedSourcesRouter,
});
+1 -1
View File
@@ -13,7 +13,7 @@
"@trpc/client": "^11.7.1",
"@trpc/server": "^11.7.1",
"@trpc/tanstack-react-query": "^11.7.1",
"better-auth": "^1.7.1",
"better-auth": "1.7.2",
"date-fns": "catalog:",
"lucide-react": "^0.554.0",
"next-themes": "^0.4.6",
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+43
View File
@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
example
# generated native folders
/ios
/android
+1
View File
@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }
+7
View File
@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34
View File
@@ -0,0 +1,34 @@
# Basango mobile
The Basango reader is a native Expo application for iOS and Android. Every reader signs in, but
account creation is open to anyone. The initial product surface includes the news feed, article
details and comments, sources and follows, bookmark collections, and account management.
## Local development
From the repository root, install dependencies and start the API and mobile application:
```bash
bun install
bun run dev:api
bun --cwd apps/mobile run start
```
The committed root `.env` points the application at `http://localhost:3080`. A physical device
cannot resolve the computer's `localhost`; override `EXPO_PUBLIC_API_URL` in the root `.env.local`
with the computer's LAN address when testing on a device.
Open the project in Expo Go or a native development build. Web is deliberately not a supported
target.
## Structure
- `src/app` contains Expo Router routes only.
- `src/application` owns authentication, runtime configuration, providers, and the typed tRPC
client.
- `src/features` groups reader capabilities.
- `src/ui` contains the application-local, shadcn-inspired React Native primitives.
- `src/global.css` owns the Uniwind theme tokens and light/dark palettes.
Authentication uses Better Auth with Expo SecureStore-backed session cookies. Password reset links
use the application scheme `basango://reset-password`.
+43
View File
@@ -0,0 +1,43 @@
{
"expo": {
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"backgroundImage": "./assets/images/android-icon-background.png",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"package": "dev.ngandu.basango",
"predictiveBackGestureEnabled": false
},
"experiments": {
"reactCompiler": true,
"typedRoutes": true
},
"githubUrl": "https://github.com/bernard-ng/basango",
"icon": "./assets/images/icon.png",
"ios": {
"bundleIdentifier": "dev.ngandu.basango",
"icon": "./assets/expo.icon",
"supportsTablet": true
},
"name": "Basango",
"orientation": "portrait",
"platforms": ["ios", "android"],
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#007AFF",
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
]
],
"scheme": "basango",
"slug": "basango",
"userInterfaceStyle": "automatic",
"version": "1.0.0"
}
}
@@ -0,0 +1,3 @@
<svg fill="none" height="606" viewBox="0 0 652 606" width="652" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+35
View File
@@ -0,0 +1,35 @@
{
"fill": {
"automatic-gradient": "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups": [
{
"layers": [
{
"image-name": "expo-symbol 2.svg",
"name": "expo-symbol 2",
"position": {
"scale": 1,
"translation-in-points": [1.1008400065293245e-5, -16.046875]
}
},
{
"image-name": "grid.png",
"name": "grid"
}
],
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+3
View File
@@ -0,0 +1,3 @@
const { getDefaultConfig } = require("expo/metro-config");
module.exports = getDefaultConfig(__dirname);
+53
View File
@@ -0,0 +1,53 @@
{
"dependencies": {
"@basango/api": "workspace:*",
"@basango/domain": "workspace:*",
"@better-auth/expo": "1.7.2",
"@hookform/resolvers": "^5.2.2",
"@tamagui/config": "^2.7.7",
"@tanstack/react-query": "^5.90.8",
"@trpc/client": "^11.7.1",
"@trpc/tanstack-react-query": "^11.7.1",
"better-auth": "1.7.2",
"date-fns": "catalog:",
"expo": "~57.0.18",
"expo-constants": "~57.0.16",
"expo-image": "~57.0.3",
"expo-linking": "~57.0.8",
"expo-network": "^57.0.1",
"expo-router": "~57.0.17",
"expo-secure-store": "^57.0.2",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"lucide-react-native": "^1.35.0",
"react": "19.2.3",
"react-hook-form": "^7.66.0",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-worklets": "0.10.1",
"superjson": "^2.2.6",
"tamagui": "^2.7.7",
"zod": "catalog:"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "npm:typescript@~6.0.3"
},
"main": "expo-router/entry",
"name": "@basango/mobile",
"private": true,
"scripts": {
"android": "expo start --android",
"dev": "expo start",
"ios": "expo start --ios",
"lint": "biome check .",
"start": "expo start",
"typecheck": "tsc --noEmit"
},
"version": "1.0.0"
}
@@ -0,0 +1,33 @@
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { DynamicColorIOS } from "react-native";
const accentColor = DynamicColorIOS({
dark: "#0a84ff",
light: "#007aff",
});
export default function TabLayout() {
return (
<NativeTabs tintColor={accentColor}>
<NativeTabs.Trigger name="articles">
<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} />
<NativeTabs.Trigger.Label>Actualités</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="sources">
<NativeTabs.Trigger.Icon sf="antenna.radiowaves.left.and.right" />
<NativeTabs.Trigger.Label>Sources</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="bookmarks">
<NativeTabs.Trigger.Icon sf={{ default: "bookmark", selected: "bookmark.fill" }} />
<NativeTabs.Trigger.Label>Signets</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="account">
<NativeTabs.Trigger.Icon sf={{ default: "person", selected: "person.fill" }} />
<NativeTabs.Trigger.Label>Profil</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
@@ -0,0 +1,23 @@
import { Stack } from "expo-router";
import { useStackScreenOptions } from "#mobile/ui/navigation/use-stack-screen-options";
import { useAppColors } from "#mobile/ui/theme";
export default function AccountLayout() {
const colors = useAppColors();
const screenOptions = useStackScreenOptions(colors.groupedBackground);
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen name="index">
<Stack.Title
large
largeStyle={{ color: colors.foreground }}
style={{ color: colors.foreground }}
>
Profil
</Stack.Title>
</Stack.Screen>
</Stack>
);
}
@@ -0,0 +1,152 @@
import { MailIcon, ShieldCheckIcon, SunMoonIcon, UserRoundIcon } from "lucide-react-native";
import { ActionSheetIOS, Alert, ScrollView } from "react-native";
import { XStack, YStack } from "tamagui";
import {
type AppearancePreference,
appearanceLabels,
useAppearance,
} from "#mobile/application/appearance";
import { authClient } from "#mobile/application/auth/auth-client";
import { GroupedIcon, GroupedRow, GroupedSection } from "#mobile/ui/components/grouped-list";
import { SourceAvatar } from "#mobile/ui/components/source-avatar";
import { LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { screenBottomPadding, screenGutter, sectionGap } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function AccountRoute() {
const { preference, resolvedScheme, setPreference } = useAppearance();
const colors = useAppColors();
const session = authClient.useSession();
const user = session.data?.user;
async function handleSignOut() {
const result = await authClient.signOut();
if (result.error) {
Alert.alert("Déconnexion impossible", result.error.message ?? "Réessayez dans un instant.");
}
}
function confirmSignOut() {
Alert.alert("Se déconnecter ?", "Vous devrez vous reconnecter pour accéder à votre compte.", [
{ style: "cancel", text: "Annuler" },
{ onPress: () => void handleSignOut(), style: "destructive", text: "Se déconnecter" },
]);
}
function chooseAppearance() {
const preferences: AppearancePreference[] = ["system", "light", "dark"];
const options = preferences.map((option) =>
option === preference ? `${appearanceLabels[option]}` : appearanceLabels[option],
);
ActionSheetIOS.showActionSheetWithOptions(
{
cancelButtonIndex: options.length,
options: [...options, "Annuler"],
title: "Apparence",
userInterfaceStyle: resolvedScheme,
},
(selectedIndex) => {
const selectedPreference = preferences[selectedIndex];
if (selectedPreference) {
setPreference(selectedPreference);
}
},
);
}
return (
<ScrollView
contentContainerStyle={{
gap: sectionGap,
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
>
{!user ? (
<LoadingState label="Chargement du profil…" />
) : (
<>
<GroupedSection>
<XStack alignItems="center" gap="$4" minHeight={88} padding="$4">
<SourceAvatar name={user.name} size="medium" />
<YStack flex={1} gap="$0.5">
<Text fontSize="$6" fontWeight="600" numberOfLines={1}>
{user.name}
</Text>
<Text numberOfLines={1} variant="caption">
{user.email}
</Text>
</YStack>
</XStack>
</GroupedSection>
<GroupedSection title="Informations personnelles">
<GroupedRow
icon={
<GroupedIcon>
<UserRoundIcon color="white" size={18} strokeWidth={1.9} />
</GroupedIcon>
}
label="Nom"
showSeparator
value={user.name}
/>
<GroupedRow
icon={
<GroupedIcon>
<MailIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Adresse e-mail"
showSeparator
value={user.email}
/>
<GroupedRow
icon={
<GroupedIcon>
<ShieldCheckIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Compte"
value={user.emailVerified ? "E-mail vérifié" : "E-mail non vérifié"}
/>
</GroupedSection>
<GroupedSection title="Apparence">
<GroupedRow
accessibilityHint="Choisit le thème système, clair ou sombre"
icon={
<GroupedIcon>
<SunMoonIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Thème"
onPress={chooseAppearance}
value={appearanceLabels[preference]}
/>
</GroupedSection>
<GroupedSection>
<GroupedRow
accessibilityHint="Ferme la session sur cet appareil"
destructive
label="Se déconnecter"
onPress={confirmSignOut}
/>
</GroupedSection>
<Text textAlign="center" variant="caption">
Basango · Lactualité qui vous rapproche
</Text>
</>
)}
</ScrollView>
);
}
@@ -0,0 +1,154 @@
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { Stack, useLocalSearchParams } from "expo-router";
import { BookmarkIcon, MessageCircleIcon, Share2Icon } from "lucide-react-native";
import { useState } from "react";
import { Linking, Share } from "react-native";
import { H5, ScrollView, Separator, XStack, YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { SourceReference } from "#mobile/features/content/articles/components/source-reference";
import { BookmarkPickerModal } from "#mobile/features/content/bookmarks/components/bookmark-picker-modal";
import { ArticleCommentsModal } from "#mobile/features/content/comments/components/article-comments";
import { formatPublicationDate } from "#mobile/features/content/shared/format-publication-date";
import { toPlainText } from "#mobile/features/content/shared/to-plain-text";
import { Button } from "#mobile/ui/components/button";
import { IconButton } from "#mobile/ui/components/icon-button";
import { Screen } from "#mobile/ui/components/screen";
import { ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { screenBottomPadding, screenGutter } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function ArticleDetailsRoute() {
const colors = useAppColors();
const { id } = useLocalSearchParams<{ id: string }>();
const trpc = useTRPC();
const article = useQuery(trpc.feed.articles.get.queryOptions({ id }));
const [isBookmarkPickerVisible, setBookmarkPickerVisible] = useState(false);
const [isCommentsVisible, setCommentsVisible] = useState(false);
async function handleShare() {
if (!article.data) {
return;
}
await Share.share({
message: `${article.data.title}\n${article.data.link}`,
title: article.data.title,
url: article.data.link,
});
}
if (article.isPending) {
return (
<Screen hasNativeHeader>
<LoadingState label="Chargement de larticle…" />
</Screen>
);
}
if (article.isError) {
return (
<Screen hasNativeHeader>
<ErrorState onRetry={() => void article.refetch()} />
</Screen>
);
}
return (
<Screen hasNativeHeader>
<Stack.Screen
options={{
headerRight: ({ tintColor }) => (
<XStack alignItems="center">
<IconButton
accessibilityLabel="Ajouter aux signets"
onPress={() => setBookmarkPickerVisible(true)}
>
<BookmarkIcon color={tintColor ?? colors.primary} size={22} strokeWidth={1.8} />
</IconButton>
<IconButton accessibilityLabel="Partager larticle" onPress={handleShare}>
<Share2Icon color={tintColor ?? colors.primary} size={22} strokeWidth={1.8} />
</IconButton>
</XStack>
),
title: "",
}}
/>
<ScrollView
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
>
{article.data.image ? (
<YStack borderRadius="$4" marginBottom="$4" overflow="hidden">
<Image
contentFit="cover"
source={{ uri: article.data.image }}
style={{ height: 225, width: "100%" }}
transition={180}
/>
</YStack>
) : null}
<YStack backgroundColor="$background" gap="$4">
{article.data.category ? (
<XStack flexWrap="wrap" gap="$2">
<Text variant="caption">{article.data.category.name.toLocaleLowerCase("fr-CD")}</Text>
</XStack>
) : null}
<H5 fontWeight="bold" marginBottom="$1">
{toPlainText(article.data.title)}
</H5>
<YStack gap="$2">
<SourceReference source={article.data.source} />
<XStack alignItems="center" height={20}>
<Text variant="caption">{formatPublicationDate(article.data.publishedAt)}</Text>
{article.data.readingTime ? (
<>
<Separator alignSelf="stretch" marginHorizontal={16} vertical />
<Text variant="caption">{article.data.readingTime} minutes de lecture</Text>
</>
) : null}
</XStack>
</YStack>
<Text fontSize={16} lineHeight={25} marginTop="$2">
{toPlainText(article.data.body)}
</Text>
<Button onPress={() => void Linking.openURL(article.data.link)} width="100%">
Consulter larticle
</Button>
<Button
icon={<MessageCircleIcon color={colors.primary} size={19} strokeWidth={1.8} />}
onPress={() => setCommentsVisible(true)}
variant="outline"
width="100%"
>
Commentaires
</Button>
</YStack>
</ScrollView>
<ArticleCommentsModal
articleId={article.data.id}
onClose={() => setCommentsVisible(false)}
visible={isCommentsVisible}
/>
<BookmarkPickerModal
articleId={article.data.id}
onClose={() => setBookmarkPickerVisible(false)}
visible={isBookmarkPickerVisible}
/>
</Screen>
);
}
@@ -0,0 +1,33 @@
import { Stack } from "expo-router";
import { useStackScreenOptions } from "#mobile/ui/navigation/use-stack-screen-options";
import { useAppColors } from "#mobile/ui/theme";
export default function ArticlesLayout() {
const colors = useAppColors();
const screenOptions = useStackScreenOptions();
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen name="index">
<Stack.Title
large
largeStyle={{ color: colors.foreground }}
style={{ color: colors.foreground }}
>
Actualités
</Stack.Title>
</Stack.Screen>
<Stack.Screen name="all">
<Stack.Title
large
largeStyle={{ color: colors.foreground }}
style={{ color: colors.foreground }}
>
Actualités
</Stack.Title>
</Stack.Screen>
<Stack.Screen name="[id]" options={{ title: "" }} />
</Stack>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { FlatList, RefreshControl } from "react-native";
import { YStack } from "tamagui";
import { ArticleCard } from "#mobile/features/content/articles/components/article-card";
import { ArticleListFooter } from "#mobile/features/content/articles/components/article-list-footer";
import { useInfiniteArticles } from "#mobile/features/content/articles/hooks/use-infinite-articles";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { screenBottomPadding, screenGutter } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function AllArticlesRoute() {
const colors = useAppColors();
const [isRefreshing, setIsRefreshing] = useState(false);
const articleFeed = useInfiniteArticles();
async function handleRefresh() {
setIsRefreshing(true);
try {
await articleFeed.refetch();
} finally {
setIsRefreshing(false);
}
}
return (
<FlatList
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
data={articleFeed.articles}
ItemSeparatorComponent={() => <YStack height="$2" />}
keyExtractor={(article) => article.id}
ListEmptyComponent={
articleFeed.isPending ? (
<LoadingState />
) : articleFeed.isError ? (
<ErrorState onRetry={() => void handleRefresh()} />
) : (
<EmptyState
description="Les prochaines publications apparaîtront ici."
title="Aucune actualité"
/>
)
}
ListFooterComponent={
<ArticleListFooter
hasError={articleFeed.isFetchNextPageError}
isLoading={articleFeed.isFetchingNextPage}
onRetry={articleFeed.loadNextPage}
/>
}
onEndReached={articleFeed.loadNextPage}
onEndReachedThreshold={0.6}
refreshControl={
<RefreshControl
colors={[colors.primary]}
onRefresh={() => void handleRefresh()}
refreshing={isRefreshing}
tintColor={colors.primary}
/>
}
renderItem={({ item }) => <ArticleCard article={item} />}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.background, flex: 1 }}
/>
);
}
@@ -0,0 +1,106 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
import { RefreshControl, ScrollView } from "react-native";
import { YStack } from "tamagui";
import { ArticleCard } from "#mobile/features/content/articles/components/article-card";
import { ArticleListFooter } from "#mobile/features/content/articles/components/article-list-footer";
import { FeaturedArticleCard } from "#mobile/features/content/articles/components/featured-article-card";
import { useInfiniteArticles } from "#mobile/features/content/articles/hooks/use-infinite-articles";
import { SectionHeader } from "#mobile/ui/components/section-header";
import { ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { screenBottomPadding, screenGutter, sectionGap } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function ArticlesHomeRoute() {
const colors = useAppColors();
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const articleFeed = useInfiniteArticles();
async function handleRefresh() {
setIsRefreshing(true);
try {
await articleFeed.refetch();
} finally {
setIsRefreshing(false);
}
}
function handleFeedScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
if (distanceFromBottom < 480) {
articleFeed.loadNextPage();
}
}
const articleItems = articleFeed.articles;
const featuredArticles = articleItems.slice(0, 5);
const latestArticles = articleItems.slice(5);
return (
<ScrollView
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
onScroll={handleFeedScroll}
refreshControl={
<RefreshControl
colors={[colors.primary]}
onRefresh={() => void handleRefresh()}
refreshing={isRefreshing}
tintColor={colors.primary}
/>
}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.background, flex: 1 }}
>
{articleFeed.isPending ? (
<LoadingState label="Chargement de lactualité…" />
) : articleFeed.isError && articleItems.length === 0 ? (
<ErrorState onRetry={() => void handleRefresh()} />
) : (
<YStack gap="$5">
<YStack gap="$2">
<SectionHeader
onAction={() => router.push("/(app)/(tabs)/articles/all")}
title="À la une"
/>
<ScrollView
contentContainerStyle={{ gap: sectionGap }}
horizontal
showsHorizontalScrollIndicator={false}
>
{featuredArticles.map((article) => (
<FeaturedArticleCard article={article} key={article.id} />
))}
</ScrollView>
</YStack>
<YStack gap="$2">
<SectionHeader
onAction={() => router.push("/(app)/(tabs)/articles/all")}
title="Dernières actualités"
/>
{latestArticles.map((article) => (
<ArticleCard article={article} key={article.id} />
))}
<ArticleListFooter
hasError={articleFeed.isFetchNextPageError}
isLoading={articleFeed.isFetchingNextPage}
onRetry={articleFeed.loadNextPage}
/>
</YStack>
</YStack>
)}
</ScrollView>
);
}
@@ -0,0 +1,171 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { MoreHorizontalIcon, Trash2Icon } from "lucide-react-native";
import { useState } from "react";
import { Alert, FlatList } from "react-native";
import { Button as TamaguiButton, YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { ArticleCard } from "#mobile/features/content/articles/components/article-card";
import { ArticleListFooter } from "#mobile/features/content/articles/components/article-list-footer";
import { BookmarkFormModal } from "#mobile/features/content/bookmarks/components/bookmark-form-modal";
import { useInfiniteBookmarkArticles } from "#mobile/features/content/bookmarks/hooks/use-infinite-bookmark-articles";
import { Button } from "#mobile/ui/components/button";
import { IconButton } from "#mobile/ui/components/icon-button";
import { Screen } from "#mobile/ui/components/screen";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { screenBottomPadding, screenGutter } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function BookmarkDetailsRoute() {
const colors = useAppColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const queryClient = useQueryClient();
const trpc = useTRPC();
const [isFormVisible, setFormVisible] = useState(false);
const bookmarks = useQuery(trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }));
const articles = useInfiniteBookmarkArticles(id);
const deleteBookmark = useMutation(
trpc.feed.bookmarks.delete.mutationOptions({
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
router.back();
},
}),
);
const removeArticle = useMutation(
trpc.feed.bookmarks.removeArticle.mutationOptions({
onSuccess() {
void articles.refetch();
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
},
}),
);
const bookmark = bookmarks.data?.items.find((item) => item.id === id);
function handleDeleteBookmark() {
Alert.alert("Supprimer ce signet ?", "La collection sera supprimée, pas ses articles.", [
{ style: "cancel", text: "Annuler" },
{
onPress: () => deleteBookmark.mutate({ id }),
style: "destructive",
text: "Supprimer",
},
]);
}
function handleRemoveArticle(articleId: string) {
Alert.alert("Retirer larticle ?", "Il ne figurera plus dans cette collection.", [
{ style: "cancel", text: "Annuler" },
{
onPress: () => removeArticle.mutate({ articleId, bookmarkId: id }),
style: "destructive",
text: "Retirer",
},
]);
}
if (bookmarks.isPending || articles.isPending) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<LoadingState label="Chargement du signet…" />
</Screen>
);
}
if (bookmarks.isError || (articles.isError && articles.articles.length === 0) || !bookmark) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<ErrorState onRetry={() => void Promise.all([bookmarks.refetch(), articles.refetch()])} />
</Screen>
);
}
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<Stack.Screen
options={{
headerRight: ({ tintColor }) => (
<IconButton
accessibilityLabel="Modifier le signet"
onPress={() => setFormVisible(true)}
>
<MoreHorizontalIcon color={tintColor ?? colors.primary} size={24} strokeWidth={1.8} />
</IconButton>
),
title: bookmark.name,
}}
/>
<FlatList
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
data={articles.articles}
ItemSeparatorComponent={() => <YStack height="$2" />}
keyExtractor={(article) => article.id}
ListEmptyComponent={
<EmptyState
description="Ouvrez un article et touchez licône de signet pour lajouter ici."
title="Collection vide"
/>
}
ListFooterComponent={
<YStack gap="$3" paddingTop="$3">
<ArticleListFooter
hasError={articles.isFetchNextPageError}
isLoading={articles.isFetchingNextPage}
onRetry={articles.loadNextPage}
/>
<Button
isLoading={deleteBookmark.isPending}
onPress={handleDeleteBookmark}
variant="destructive"
>
Supprimer le signet
</Button>
</YStack>
}
ListHeaderComponent={
<YStack gap="$2" marginBottom="$4">
{bookmark.description ? <Text variant="caption">{bookmark.description}</Text> : null}
<Text color="$primary" variant="caption">
{bookmark.articlesCount} articles · {bookmark.isPublic ? "Public" : "Privé"}
</Text>
</YStack>
}
onEndReached={articles.loadNextPage}
onEndReachedThreshold={0.6}
renderItem={({ item }) => (
<YStack gap="$1">
<ArticleCard article={item} />
<TamaguiButton
alignSelf="flex-end"
backgroundColor="transparent"
borderWidth={0}
height={34}
icon={<Trash2Icon color={colors.muted} size={15} strokeWidth={1.8} />}
marginBottom="$3"
onPress={() => handleRemoveArticle(item.id)}
paddingHorizontal="$1"
pressStyle={{ opacity: 0.7 }}
>
Retirer
</TamaguiButton>
</YStack>
)}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
/>
<BookmarkFormModal
bookmark={bookmark}
onClose={() => setFormVisible(false)}
visible={isFormVisible}
/>
</Screen>
);
}
@@ -0,0 +1,24 @@
import { Stack } from "expo-router";
import { useStackScreenOptions } from "#mobile/ui/navigation/use-stack-screen-options";
import { useAppColors } from "#mobile/ui/theme";
export default function BookmarksLayout() {
const colors = useAppColors();
const screenOptions = useStackScreenOptions(colors.groupedBackground);
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen name="index">
<Stack.Title
large
largeStyle={{ color: colors.foreground }}
style={{ color: colors.foreground }}
>
Signets
</Stack.Title>
</Stack.Screen>
<Stack.Screen name="[id]" options={{ title: "" }} />
</Stack>
);
}
@@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { PlusIcon } from "lucide-react-native";
import { useState } from "react";
import { RefreshControl, ScrollView } from "react-native";
import { YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { BookmarkCard } from "#mobile/features/content/bookmarks/components/bookmark-card";
import { BookmarkFormModal } from "#mobile/features/content/bookmarks/components/bookmark-form-modal";
import { IconButton } from "#mobile/ui/components/icon-button";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { screenBottomPadding, screenGutter, sectionGap } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function BookmarksRoute() {
const colors = useAppColors();
const trpc = useTRPC();
const [isFormVisible, setFormVisible] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const bookmarks = useQuery(trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }));
const bookmarkItems = bookmarks.data?.items ?? [];
async function handleRefresh() {
setIsRefreshing(true);
await bookmarks.refetch();
setIsRefreshing(false);
}
return (
<>
<Stack.Screen
options={{
headerRight: () => (
<IconButton accessibilityLabel="Créer un signet" onPress={() => setFormVisible(true)}>
<PlusIcon color={colors.primary} size={24} strokeWidth={1.8} />
</IconButton>
),
}}
/>
<ScrollView
contentContainerStyle={{
gap: sectionGap,
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
colors={[colors.primary]}
onRefresh={() => void handleRefresh()}
refreshing={isRefreshing}
tintColor={colors.primary}
/>
}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
>
{bookmarks.isPending ? (
<LoadingState label="Chargement des signets…" />
) : bookmarks.isError ? (
<ErrorState onRetry={() => void handleRefresh()} />
) : bookmarkItems.length === 0 ? (
<EmptyState
description="Créez une collection, puis ajoutez-y des articles."
title="Aucun signet"
/>
) : (
<YStack backgroundColor="$card" borderRadius="$5" overflow="hidden">
{bookmarkItems.map((bookmark, index) => (
<BookmarkCard
bookmark={bookmark}
key={bookmark.id}
showSeparator={index < bookmarkItems.length - 1}
/>
))}
</YStack>
)}
</ScrollView>
<BookmarkFormModal onClose={() => setFormVisible(false)} visible={isFormVisible} />
</>
);
}
@@ -0,0 +1,144 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { ExternalLinkIcon } from "lucide-react-native";
import { useState } from "react";
import { FlatList, Linking, RefreshControl, StyleSheet } from "react-native";
import { XStack, YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { ArticleCard } from "#mobile/features/content/articles/components/article-card";
import { ArticleListFooter } from "#mobile/features/content/articles/components/article-list-footer";
import { useInfiniteArticles } from "#mobile/features/content/articles/hooks/use-infinite-articles";
import { FollowButton } from "#mobile/features/content/sources/components/follow-button";
import { Button } from "#mobile/ui/components/button";
import { Screen } from "#mobile/ui/components/screen";
import { SourceAvatar } from "#mobile/ui/components/source-avatar";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { screenBottomPadding, screenGutter } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function SourceDetailsRoute() {
const colors = useAppColors();
const { id } = useLocalSearchParams<{ id: string }>();
const trpc = useTRPC();
const [isRefreshing, setIsRefreshing] = useState(false);
const source = useQuery(trpc.feed.sources.get.queryOptions({ id }));
const articleFeed = useInfiniteArticles({ sourceId: id });
async function handleRefresh() {
setIsRefreshing(true);
try {
await Promise.all([source.refetch(), articleFeed.refetch()]);
} finally {
setIsRefreshing(false);
}
}
if (source.isPending || articleFeed.isPending) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<LoadingState label="Chargement de la source…" />
</Screen>
);
}
if (source.isError || (articleFeed.isError && articleFeed.articles.length === 0)) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<ErrorState onRetry={() => void handleRefresh()} />
</Screen>
);
}
const displayName = source.data.displayName ?? source.data.name;
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<Stack.Screen
options={{
contentStyle: { backgroundColor: colors.groupedBackground },
title: displayName,
}}
/>
<FlatList
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
data={articleFeed.articles}
ItemSeparatorComponent={() => <YStack height="$2" />}
keyExtractor={(article) => article.id}
ListEmptyComponent={
<EmptyState
description="Les prochaines publications apparaîtront ici."
title="Aucun article"
/>
}
ListFooterComponent={
<ArticleListFooter
hasError={articleFeed.isFetchNextPageError}
isLoading={articleFeed.isFetchingNextPage}
onRetry={articleFeed.loadNextPage}
/>
}
ListHeaderComponent={
<YStack gap="$5" marginBottom="$3">
<YStack backgroundColor="$card" borderRadius="$5" overflow="hidden">
<XStack alignItems="center" gap="$3" padding="$4">
<SourceAvatar name={displayName} size="large" />
<YStack flex={1} gap="$1">
<Text fontSize="$5" fontWeight="600" numberOfLines={2}>
{displayName}
</Text>
<Text variant="caption">{source.data.articlesCount} articles</Text>
{source.data.description ? (
<Text color="$colorHover" fontSize="$3" numberOfLines={3}>
{source.data.description}
</Text>
) : null}
</YStack>
</XStack>
<XStack
borderTopColor="$separator"
borderTopWidth={StyleSheet.hairlineWidth}
gap="$3"
padding="$3"
>
<FollowButton presentation="regular" source={source.data} />
<Button
flex={1}
onPress={() => void Linking.openURL(source.data.url)}
variant="outline"
>
<ExternalLinkIcon color={colors.foreground} size={18} strokeWidth={1.8} />
<Text fontWeight="600">Voir le site</Text>
</Button>
</XStack>
</YStack>
<Text fontSize="$6" fontWeight="700">
Dernières publications
</Text>
</YStack>
}
onEndReached={articleFeed.loadNextPage}
onEndReachedThreshold={0.6}
refreshControl={
<RefreshControl
colors={[colors.primary]}
onRefresh={() => void handleRefresh()}
refreshing={isRefreshing}
tintColor={colors.primary}
/>
}
renderItem={({ item }) => <ArticleCard article={item} showSource={false} />}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
/>
</Screen>
);
}
@@ -0,0 +1,24 @@
import { Stack } from "expo-router";
import { useStackScreenOptions } from "#mobile/ui/navigation/use-stack-screen-options";
import { useAppColors } from "#mobile/ui/theme";
export default function SourcesLayout() {
const colors = useAppColors();
const screenOptions = useStackScreenOptions(colors.groupedBackground);
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen name="index">
<Stack.Title
large
largeStyle={{ color: colors.foreground }}
style={{ color: colors.foreground }}
>
Sources
</Stack.Title>
</Stack.Screen>
<Stack.Screen name="[id]" options={{ title: "Source" }} />
</Stack>
);
}
@@ -0,0 +1,134 @@
import { useQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useState } from "react";
import { RefreshControl, ScrollView } from "react-native";
import { YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { SourceCard } from "#mobile/features/content/sources/components/source-card";
import { SectionHeader } from "#mobile/ui/components/section-header";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { screenBottomPadding, screenGutter, sectionGap } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function SourcesRoute() {
const colors = useAppColors();
const trpc = useTRPC();
const [isRefreshing, setIsRefreshing] = useState(false);
const [search, setSearch] = useState("");
const sources = useQuery(
trpc.feed.sources.list.queryOptions({
limit: 100,
page: 1,
search: search.trim() || undefined,
}),
);
const followedSources = useQuery(
trpc.feed.sources.list.queryOptions({ followedOnly: true, limit: 100, page: 1 }),
);
const sourceItems = sources.data?.items ?? [];
const followedItems = followedSources.data?.items ?? [];
const followedIds = new Set(followedItems.map((source) => source.id));
const discoveryItems = sourceItems.filter((source) => !followedIds.has(source.id));
const isSearching = search.trim().length > 0;
async function handleRefresh() {
setIsRefreshing(true);
await Promise.all([sources.refetch(), followedSources.refetch()]);
setIsRefreshing(false);
}
return (
<>
<Stack.Screen
options={{
contentStyle: { backgroundColor: colors.groupedBackground },
headerSearchBarOptions: {
hideWhenScrolling: false,
onChangeText: (event) => setSearch(event.nativeEvent.text),
placeholder: "Rechercher une source",
placement: "integratedButton",
tintColor: colors.primary,
},
}}
/>
<ScrollView
contentContainerStyle={{
gap: sectionGap,
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
colors={[colors.primary]}
onRefresh={() => void handleRefresh()}
refreshing={isRefreshing}
tintColor={colors.primary}
/>
}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
>
{sources.isPending || followedSources.isPending ? (
<LoadingState label="Chargement des sources…" />
) : sources.isError || followedSources.isError ? (
<ErrorState onRetry={() => void handleRefresh()} />
) : sourceItems.length === 0 ? (
<EmptyState
description="Aucune source ne correspond à votre recherche."
title="Aucun résultat"
/>
) : isSearching ? (
<YStack gap="$2">
<SectionHeader title="Résultats" />
<YStack backgroundColor="$card" borderRadius="$5" paddingHorizontal="$3">
{sourceItems.map((source, index) => (
<SourceCard
key={source.id}
showSeparator={index < sourceItems.length - 1}
source={source}
/>
))}
</YStack>
</YStack>
) : (
<>
{followedItems.length > 0 ? (
<YStack gap="$2">
<SectionHeader title="Sources suivies" />
<YStack backgroundColor="$card" borderRadius="$5" paddingHorizontal="$3">
{followedItems.map((source, index) => (
<SourceCard
key={source.id}
showSeparator={index < followedItems.length - 1}
source={source}
/>
))}
</YStack>
</YStack>
) : null}
{discoveryItems.length > 0 ? (
<YStack gap="$2">
<SectionHeader
title={followedItems.length > 0 ? "À découvrir" : "Toutes les sources"}
/>
<YStack backgroundColor="$card" borderRadius="$5" paddingHorizontal="$3">
{discoveryItems.map((source, index) => (
<SourceCard
key={source.id}
showSeparator={index < discoveryItems.length - 1}
source={source}
/>
))}
</YStack>
</YStack>
) : null}
</>
)}
</ScrollView>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AppLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}
+16
View File
@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import { useStackScreenOptions } from "#mobile/ui/navigation/use-stack-screen-options";
export default function AuthLayout() {
const screenOptions = useStackScreenOptions();
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen name="welcome" options={{ headerShown: false }} />
<Stack.Screen name="sign-in" options={{ title: "Connexion" }} />
<Stack.Screen name="sign-up" options={{ title: "Créer un compte" }} />
<Stack.Screen name="forgot-password" options={{ title: "Mot de passe oublié" }} />
</Stack>
);
}
@@ -0,0 +1,106 @@
import { requestPasswordResetSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type ForgotPasswordForm = z.infer<typeof requestPasswordResetSchema>;
export default function ForgotPasswordRoute() {
const form = useForm<ForgotPasswordForm>({
defaultValues: { email: "" },
mode: "onChange",
resolver: zodResolver(requestPasswordResetSchema),
});
const message = form.formState.errors.root?.message;
async function handleRequest(values: ForgotPasswordForm) {
form.clearErrors("root");
const result = await authClient.requestPasswordReset({
email: values.email,
redirectTo: "basango://reset-password",
});
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(
result.error,
"Impossible denvoyer le lien de réinitialisation.",
),
});
return;
}
form.setError("root", {
message: "Si cette adresse est associée à un compte, un lien vient de vous être envoyé.",
type: "success",
});
}
const isSuccess = form.formState.errors.root?.type === "success";
return (
<KeyboardScreen hasNativeHeader>
<YStack
flex={1}
gap="$4"
justifyContent="space-between"
paddingBottom="$6"
paddingHorizontal="$4"
paddingTop="$5"
>
<YStack flex={1} gap="$4">
<Text>
Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation de mot de
passe.
</Text>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
{message ? (
<Text color={isSuccess ? "$primary" : "$danger"} variant="caption">
{message}
</Text>
) : null}
<Link asChild href="/(auth)/sign-in">
<Text>Vous avez déjà un compte ? Se connecter</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleRequest)}
>
Réinitialiser le mot de passe
</Button>
</YStack>
</KeyboardScreen>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { loginSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { ScrollView, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { PasswordInput } from "#mobile/ui/components/password-input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type LoginForm = z.infer<typeof loginSchema>;
export default function SignInRoute() {
const form = useForm<LoginForm>({
defaultValues: { email: "", password: "" },
mode: "onChange",
resolver: zodResolver(loginSchema),
});
const error = form.formState.errors.root?.message;
async function handleSignIn(values: LoginForm) {
form.clearErrors("root");
const result = await authClient.signIn.email(values);
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(
result.error,
"Vérifiez votre adresse e-mail et votre mot de passe.",
),
});
}
}
return (
<KeyboardScreen hasNativeHeader>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
gap: 16,
justifyContent: "space-between",
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 20,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<YStack flex={1} gap="$4">
<Text>Bienvenue sur Basango, la plateforme dactualités intelligente.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
error={fieldState.error?.message}
label="Mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Votre mot de passe"
value={field.value}
/>
)}
/>
<Link asChild href="/(auth)/forgot-password">
<Text color="$primary">Mot de passe oublié ?</Text>
</Link>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
<Text variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez
avoir lu notre politique de confidentialité.
</Text>
<Link asChild href="/(auth)/sign-up">
<Text>Vous navez pas de compte ? Créer un compte</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSignIn)}
>
Se connecter
</Button>
</ScrollView>
</KeyboardScreen>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { signUpSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { ScrollView, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { PasswordInput } from "#mobile/ui/components/password-input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type SignUpForm = z.infer<typeof signUpSchema>;
export default function SignUpRoute() {
const form = useForm<SignUpForm>({
defaultValues: { email: "", name: "", password: "" },
mode: "onChange",
resolver: zodResolver(signUpSchema),
});
const error = form.formState.errors.root?.message;
async function handleSignUp(values: SignUpForm) {
form.clearErrors("root");
const result = await authClient.signUp.email(values);
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(result.error, "Impossible de créer votre compte."),
});
}
}
return (
<KeyboardScreen hasNativeHeader>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
gap: 16,
justifyContent: "space-between",
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 20,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<YStack flex={1} gap="$4">
<Text>Rejoignez la communauté Basango et restez informé des dernières actualités.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Input
autoComplete="name"
error={fieldState.error?.message}
label="Nom complet"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Votre nom"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="8 caractères minimum"
value={field.value}
/>
)}
/>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
<Text variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez
avoir lu notre politique de confidentialité.
</Text>
<Link asChild href="/(auth)/sign-in">
<Text>Vous avez un compte ? Connectez-vous</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSignUp)}
>
Créer un compte
</Button>
</ScrollView>
</KeyboardScreen>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useRouter } from "expo-router";
import { YStack } from "tamagui";
import { Button } from "#mobile/ui/components/button";
import { LogoMark } from "#mobile/ui/components/logo-mark";
import { Screen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
export default function WelcomeRoute() {
const router = useRouter();
return (
<Screen alignItems="center" gap="$4" justifyContent="center" paddingHorizontal="$4">
<LogoMark />
<YStack gap="$6" width="100%">
<YStack gap="$3">
<Text textAlign="center" variant="display">
Bienvenue sur Basango
</Text>
<Text lineHeight="$1" marginTop="auto" textAlign="center">
La première plateforme dactualités intelligente qui vous aide à rester informé sur
lactualité congolaise et internationale.
</Text>
</YStack>
<YStack gap="$4">
<Button onPress={() => router.push("/(auth)/sign-in")}>Se connecter</Button>
<Text onPress={() => router.push("/(auth)/sign-up")} textAlign="center">
Ouvrir un compte
</Text>
</YStack>
<Text textAlign="center" variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez avoir
lu notre politique de confidentialité.
</Text>
</YStack>
</Screen>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useRouter } from "expo-router";
import { Button } from "#mobile/ui/components/button";
import { Screen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
export default function NotFoundRoute() {
const router = useRouter();
return (
<Screen alignItems="center" gap="$4" justifyContent="center" paddingHorizontal="$8">
<Text textAlign="center" variant="heading">
Cette page nexiste pas
</Text>
<Text textAlign="center" variant="caption">
Revenez aux actualités pour continuer.
</Text>
<Button onPress={() => router.replace("/")}>Retour à laccueil</Button>
</Screen>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { authClient } from "#mobile/application/auth/auth-client";
import { RootProvider } from "#mobile/application/root-provider";
import { LoadingState } from "#mobile/ui/components/status-state";
import { useStackScreenOptions } from "#mobile/ui/navigation/use-stack-screen-options";
void SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
return (
<RootProvider>
<RootNavigator />
</RootProvider>
);
}
function RootNavigator() {
const screenOptions = useStackScreenOptions();
const session = authClient.useSession();
useEffect(() => {
if (!session.isPending) {
void SplashScreen.hideAsync();
}
}, [session.isPending]);
if (session.isPending) {
return <LoadingState label="Ouverture de Basango…" />;
}
const isAuthenticated = Boolean(session.data);
return (
<Stack screenOptions={{ ...screenOptions, headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="reset-password"
options={{ headerShown: true, title: "Nouveau mot de passe" }}
/>
<Stack.Protected guard={!isAuthenticated}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated}>
<Stack.Screen name="(app)" />
</Stack.Protected>
</Stack>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Redirect } from "expo-router";
import { authClient } from "#mobile/application/auth/auth-client";
export default function IndexRoute() {
const session = authClient.useSession();
if (session.isPending) {
return null;
}
return session.data ? (
<Redirect href="/(app)/(tabs)/articles" />
) : (
<Redirect href="/(auth)/welcome" />
);
}
+112
View File
@@ -0,0 +1,112 @@
import { resetPasswordSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { PasswordInput } from "#mobile/ui/components/password-input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type ResetPasswordForm = z.infer<typeof resetPasswordSchema>;
export default function ResetPasswordRoute() {
const params = useLocalSearchParams<{ token?: string | string[] }>();
const router = useRouter();
const token = firstParam(params.token);
const form = useForm<ResetPasswordForm>({
defaultValues: { confirmPassword: "", password: "" },
mode: "onChange",
resolver: zodResolver(resetPasswordSchema),
});
const error = form.formState.errors.root?.message;
async function handleReset(values: ResetPasswordForm) {
if (!token) {
form.setError("root", { message: "Ce lien de réinitialisation est incomplet." });
return;
}
form.clearErrors("root");
const result = await authClient.resetPassword({ newPassword: values.password, token });
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(result.error, "Ce lien est invalide ou a expiré."),
});
return;
}
router.replace("/(auth)/sign-in");
}
return (
<KeyboardScreen hasNativeHeader>
<YStack
flex={1}
gap="$4"
justifyContent="space-between"
paddingBottom="$6"
paddingHorizontal="$4"
paddingTop="$5"
>
<YStack flex={1} gap="$4">
<Text>Choisissez un mot de passe dau moins huit caractères.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Nouveau mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="confirmPassword"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Confirmer le mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
</YStack>
<Button
disabled={!form.formState.isValid || !token}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleReset)}
>
Enregistrer le mot de passe
</Button>
</YStack>
</KeyboardScreen>
);
}
function firstParam(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@@ -0,0 +1,79 @@
import * as SecureStore from "expo-secure-store";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Appearance, useColorScheme } from "react-native";
import { TamaguiProvider } from "tamagui";
import { tamaguiConfig } from "../../tamagui.config";
export type AppearancePreference = "dark" | "light" | "system";
type AppearanceContextValue = {
preference: AppearancePreference;
resolvedScheme: "dark" | "light";
setPreference: (preference: AppearancePreference) => void;
};
const appearanceStorageKey = "basango.appearance";
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export const appearanceLabels: Record<AppearancePreference, string> = {
dark: "Sombre",
light: "Clair",
system: "Système",
};
export function AppearanceProvider({ children }: PropsWithChildren) {
const systemScheme = useColorScheme();
const [preference, setPreferenceState] = useState<AppearancePreference>(getStoredPreference);
useEffect(() => {
Appearance.setColorScheme(preference === "system" ? "unspecified" : preference);
}, [preference]);
const setPreference = useCallback((nextPreference: AppearancePreference) => {
setPreferenceState(nextPreference);
void SecureStore.setItemAsync(appearanceStorageKey, nextPreference).catch(() => undefined);
}, []);
const resolvedScheme =
preference === "dark" || preference === "light"
? preference
: systemScheme === "dark"
? "dark"
: "light";
const value = useMemo(
() => ({ preference, resolvedScheme, setPreference }),
[preference, resolvedScheme, setPreference],
);
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme={resolvedScheme}>
<AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>
</TamaguiProvider>
);
}
export function useAppearance() {
const context = useContext(AppearanceContext);
if (!context) {
throw new Error("useAppearance must be used inside AppearanceProvider");
}
return context;
}
function isAppearancePreference(value: string | null): value is AppearancePreference {
return value === "dark" || value === "light" || value === "system";
}
function getStoredPreference(): AppearancePreference {
try {
const storedPreference = SecureStore.getItem(appearanceStorageKey);
return isAppearancePreference(storedPreference) ? storedPreference : "system";
} catch {
return "system";
}
}
@@ -0,0 +1,16 @@
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
import { getPublicApiUrl } from "#mobile/application/environment";
export const authClient = createAuthClient({
baseURL: getPublicApiUrl(),
plugins: [
expoClient({
scheme: "basango",
storage: SecureStore,
storagePrefix: "basango",
}),
],
});
@@ -0,0 +1,7 @@
import z from "zod";
const apiUrlSchema = z.url();
export function getPublicApiUrl(): string {
return apiUrlSchema.parse(process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3080");
}
@@ -0,0 +1,37 @@
import { focusManager, onlineManager } from "@tanstack/react-query";
import * as Network from "expo-network";
import type { PropsWithChildren } from "react";
import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AppearanceProvider } from "#mobile/application/appearance";
import { DataProvider } from "#mobile/application/trpc/client";
focusManager.setEventListener((handleFocus) => {
const subscription = AppState.addEventListener("change", (state) => {
handleFocus(state === "active");
});
return () => subscription.remove();
});
onlineManager.setEventListener((setOnline) => {
const subscription = Network.addNetworkStateListener((state) => {
setOnline(Boolean(state.isConnected));
});
return () => subscription.remove();
});
export function RootProvider(props: PropsWithChildren) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<AppearanceProvider>
<SafeAreaProvider>
<DataProvider>{props.children}</DataProvider>
</SafeAreaProvider>
</AppearanceProvider>
</GestureHandlerRootView>
);
}
@@ -0,0 +1,70 @@
import type { AppRouter } from "@basango/api/trpc/routers/_app";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { PropsWithChildren } from "react";
import { useEffect, useRef, useState } from "react";
import superjson from "superjson";
import { authClient } from "#mobile/application/auth/auth-client";
import { getPublicApiUrl } from "#mobile/application/environment";
const {
TRPCProvider,
useTRPC: useTRPCContext,
useTRPCClient: useTRPCClientContext,
} = createTRPCContext<AppRouter>();
export const useTRPC = useTRPCContext;
export const useTRPCClient = useTRPCClientContext;
export function DataProvider(props: PropsWithChildren) {
const session = authClient.useSession();
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 30_000,
},
},
}),
);
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
fetch(url, options) {
return globalThis.fetch(url, { ...options, credentials: "omit" });
},
async headers() {
const cookie = await authClient.getCookie();
return cookie ? { Cookie: cookie } : {};
},
transformer: superjson,
url: `${getPublicApiUrl()}/trpc`,
}),
],
}),
);
const activeUserId = useRef(session.data?.user.id);
useEffect(() => {
const userId = session.data?.user.id;
if (activeUserId.current !== userId) {
queryClient.clear();
activeUserId.current = userId;
}
}, [queryClient, session.data?.user.id]);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,53 @@
import { Image } from "expo-image";
import { Link } from "expo-router";
import { XStack, YStack } from "tamagui";
import { SourceReference } from "#mobile/features/content/articles/components/source-reference";
import { formatRelativeTime } from "#mobile/features/content/shared/format-relative-time";
import { toPlainText } from "#mobile/features/content/shared/to-plain-text";
import type { ArticleOverview } from "#mobile/features/content/types";
import { Text } from "#mobile/ui/components/text";
type ArticleCardProps = {
article: ArticleOverview;
showSource?: boolean;
};
export function ArticleCard({ article, showSource = true }: ArticleCardProps) {
return (
<Link asChild href={{ params: { id: article.id }, pathname: "/(app)/(tabs)/articles/[id]" }}>
<YStack paddingVertical="$2" pressStyle={{ opacity: 0.72 }}>
<XStack alignItems="center" gap="$3">
<YStack flex={1} gap="$2">
<Text fontSize="$5" fontWeight="600" numberOfLines={2}>
{toPlainText(article.title)}
</Text>
{article.excerpt ? (
<Text color="$colorHover" fontSize="$3" numberOfLines={2}>
{toPlainText(article.excerpt)}
</Text>
) : null}
</YStack>
{article.image ? (
<Image
contentFit="cover"
source={{ uri: article.image }}
style={{ borderRadius: 12, height: 90, width: 120 }}
transition={180}
/>
) : null}
</XStack>
<XStack
alignItems="center"
justifyContent={showSource ? "space-between" : "flex-start"}
marginTop="$3"
>
{showSource ? <SourceReference source={article.source} /> : null}
<Text flexShrink={0} variant="caption">
{formatRelativeTime(article.publishedAt)}
</Text>
</XStack>
</YStack>
</Link>
);
}
@@ -0,0 +1,27 @@
import { Spinner, YStack } from "tamagui";
import { Button } from "#mobile/ui/components/button";
type ArticleListFooterProps = {
hasError: boolean;
isLoading: boolean;
onRetry: () => void;
};
export function ArticleListFooter({ hasError, isLoading, onRetry }: ArticleListFooterProps) {
if (!hasError && !isLoading) {
return null;
}
return (
<YStack alignItems="center" paddingVertical="$4">
{isLoading ? (
<Spinner color="$primary" size="small" />
) : (
<Button onPress={onRetry} size="$2" variant="ghost">
Réessayer
</Button>
)}
</YStack>
);
}
@@ -0,0 +1,54 @@
import { Image } from "expo-image";
import { Link } from "expo-router";
import { Dimensions } from "react-native";
import { XStack, YStack } from "tamagui";
import { SourceReference } from "#mobile/features/content/articles/components/source-reference";
import { formatRelativeTime } from "#mobile/features/content/shared/format-relative-time";
import { toPlainText } from "#mobile/features/content/shared/to-plain-text";
import type { ArticleOverview } from "#mobile/features/content/types";
import { Text } from "#mobile/ui/components/text";
type FeaturedArticleCardProps = {
article: ArticleOverview;
};
const { width: screenWidth } = Dimensions.get("window");
export function FeaturedArticleCard({ article }: FeaturedArticleCardProps) {
return (
<Link asChild href={{ params: { id: article.id }, pathname: "/(app)/(tabs)/articles/[id]" }}>
<YStack pressStyle={{ opacity: 0.72 }} width={screenWidth * 0.7}>
<YStack backgroundColor="$surface" borderRadius="$4" height={200} overflow="hidden">
{article.image ? (
<Image
contentFit="cover"
source={{ uri: article.image }}
style={{ height: "100%", width: "100%" }}
/>
) : (
<YStack alignItems="center" backgroundColor="$surface" flex={1} justifyContent="center">
<Text color="$primary" fontWeight="800" variant="heading">
Basango
</Text>
</YStack>
)}
</YStack>
<YStack gap="$2" marginTop="$2">
<Text fontSize="$5" fontWeight="600" numberOfLines={2}>
{toPlainText(article.title)}
</Text>
{article.excerpt ? (
<Text fontSize="$3" numberOfLines={2}>
{toPlainText(article.excerpt)}
</Text>
) : null}
</YStack>
<XStack alignItems="center" justifyContent="space-between" marginTop="$2">
<SourceReference source={article.source} />
<Text variant="caption">{formatRelativeTime(article.publishedAt)}</Text>
</XStack>
</YStack>
</Link>
);
}
@@ -0,0 +1,20 @@
import { XStack } from "tamagui";
import type { ArticleOverview } from "#mobile/features/content/types";
import { SourceAvatar } from "#mobile/ui/components/source-avatar";
import { Text } from "#mobile/ui/components/text";
type SourceReferenceProps = {
source: ArticleOverview["source"];
};
export function SourceReference({ source }: SourceReferenceProps) {
return (
<XStack alignItems="center" gap="$2">
<SourceAvatar name={source.displayName ?? source.name} size="small" />
<Text fontSize="$2" fontWeight="bold" maxWidth={176} numberOfLines={1}>
{source.displayName ?? source.name}
</Text>
</XStack>
);
}
@@ -0,0 +1,66 @@
import type { RouterInputs, RouterOutputs } from "@basango/api/trpc/routers/_app";
import { type InfiniteData, type QueryKey, useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTRPC, useTRPCClient } from "#mobile/application/trpc/client";
import { getNextPage } from "#mobile/features/content/shared/get-next-page";
export const ARTICLES_PAGE_SIZE = 20;
type ArticleFilters = Omit<RouterInputs["feed"]["articles"]["list"], "limit" | "page">;
type ArticlePage = RouterOutputs["feed"]["articles"]["list"];
type UseInfiniteArticlesOptions = ArticleFilters & {
enabled?: boolean;
};
export function useInfiniteArticles({
categoryId,
enabled = true,
search,
sourceId,
}: UseInfiniteArticlesOptions = {}) {
const trpc = useTRPC();
const trpcClient = useTRPCClient();
const filters = { categoryId, search, sourceId };
const queryKey = trpc.feed.articles.list.queryKey({
...filters,
limit: ARTICLES_PAGE_SIZE,
});
const query = useInfiniteQuery<
ArticlePage,
Error,
InfiniteData<ArticlePage, number>,
QueryKey,
number
>({
enabled,
getNextPageParam: (lastPage) => getNextPage(lastPage.meta),
initialPageParam: 1,
queryFn: async ({ pageParam, signal }): Promise<ArticlePage> =>
trpcClient.feed.articles.list.query(
{ ...filters, limit: ARTICLES_PAGE_SIZE, page: pageParam },
{ signal },
),
queryKey: [...queryKey, "infinite-pages"],
});
const articles = useMemo(() => {
const uniqueArticles = new Map(
query.data?.pages.flatMap((page) => page.items).map((article) => [article.id, article]),
);
return [...uniqueArticles.values()];
}, [query.data?.pages]);
const loadNextPage = useCallback(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage();
}
}, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]);
return {
...query,
articles,
loadNextPage,
total: query.data?.pages[0]?.meta.total ?? 0,
};
}
@@ -0,0 +1,56 @@
import { Link } from "expo-router";
import { BookmarkIcon, ChevronRightIcon, Globe2Icon, LockIcon } from "lucide-react-native";
import { StyleSheet } from "react-native";
import { XStack, YStack } from "tamagui";
import type { Bookmark } from "#mobile/features/content/types";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
type BookmarkCardProps = {
bookmark: Bookmark;
showSeparator?: boolean;
};
export function BookmarkCard({ bookmark, showSeparator = false }: BookmarkCardProps) {
const colors = useAppColors();
return (
<Link asChild href={{ params: { id: bookmark.id }, pathname: "/(app)/(tabs)/bookmarks/[id]" }}>
<XStack
alignItems="center"
borderBottomColor="$separator"
borderBottomWidth={showSeparator ? StyleSheet.hairlineWidth : 0}
gap="$4"
paddingHorizontal="$4"
paddingVertical="$3"
pressStyle={{ opacity: 0.72 }}
>
<YStack
alignItems="center"
backgroundColor="$surface"
borderRadius="$5"
height={52}
justifyContent="center"
width={52}
>
<BookmarkIcon color={colors.primary} size={23} strokeWidth={1.8} />
</YStack>
<YStack flex={1} gap="$1">
<Text numberOfLines={1} variant="title">
{bookmark.name}
</Text>
<XStack alignItems="center" gap="$2">
<Text variant="caption">{bookmark.articlesCount} articles</Text>
{bookmark.isPublic ? (
<Globe2Icon color={colors.muted} size={14} strokeWidth={1.8} />
) : (
<LockIcon color={colors.muted} size={14} strokeWidth={1.8} />
)}
</XStack>
</YStack>
<ChevronRightIcon color={colors.muted} size={20} strokeWidth={1.8} />
</XStack>
</Link>
);
}
@@ -0,0 +1,177 @@
import { createBookmarkSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { ScrollView, XStack, YStack } from "tamagui";
import type z from "zod";
import { useTRPC } from "#mobile/application/trpc/client";
import type { Bookmark } from "#mobile/features/content/types";
import { BottomSheetModal } from "#mobile/ui/components/bottom-sheet-modal";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { Switch } from "#mobile/ui/components/switch";
import { Text } from "#mobile/ui/components/text";
const bookmarkFormSchema = createBookmarkSchema;
type BookmarkForm = z.input<typeof bookmarkFormSchema>;
type BookmarkFormModalProps = {
bookmark?: Bookmark;
onClose: () => void;
visible: boolean;
};
export function BookmarkFormModal({ bookmark, onClose, visible }: BookmarkFormModalProps) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const form = useForm<BookmarkForm>({
defaultValues: { description: "", isPublic: false, name: "" },
mode: "onChange",
resolver: zodResolver(bookmarkFormSchema),
});
const createBookmark = useMutation(
trpc.feed.bookmarks.create.mutationOptions({
onError(error) {
form.setError("root", { message: error.message || "Impossible de créer ce signet." });
},
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
form.reset();
onClose();
},
}),
);
const updateBookmark = useMutation(
trpc.feed.bookmarks.update.mutationOptions({
onError(error) {
form.setError("root", { message: error.message || "Impossible de modifier ce signet." });
},
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
form.reset();
onClose();
},
}),
);
useEffect(() => {
if (!visible) {
return;
}
form.reset({
description: bookmark?.description ?? "",
isPublic: bookmark?.isPublic ?? false,
name: bookmark?.name ?? "",
});
}, [bookmark, form, visible]);
function handleSubmit(values: BookmarkForm) {
const input = {
description: values.description?.trim() || undefined,
isPublic: values.isPublic ?? false,
name: values.name,
};
if (bookmark) {
updateBookmark.mutate({ id: bookmark.id, ...input });
return;
}
createBookmark.mutate(input);
}
return (
<BottomSheetModal
onClose={onClose}
title={bookmark ? "Modifier le signet" : "Nouveau signet"}
visible={visible}
>
<ScrollView
contentContainerStyle={{ gap: 20, paddingBottom: 24, paddingHorizontal: 20 }}
keyboardShouldPersistTaps="handled"
>
<Text variant="caption">
{bookmark
? "Mettez à jour le nom et la visibilité de cette collection."
: "Créez une collection pour retrouver facilement vos articles."}
</Text>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Input
error={fieldState.error?.message}
label="Nom"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="À lire plus tard"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Input
error={fieldState.error?.message}
label="Description"
multiline
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Une note facultative"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="isPublic"
render={({ field }) => (
<XStack
alignItems="center"
backgroundColor="$card"
borderColor="$borderColor"
borderRadius="$4"
borderWidth={1}
gap="$4"
justifyContent="space-between"
minHeight={60}
paddingHorizontal="$4"
paddingVertical="$3"
>
<YStack flex={1} gap="$1">
<Text variant="label">Collection publique</Text>
<Text variant="caption">Prépare le partage de cette collection.</Text>
</YStack>
<Switch
accessibilityLabel="Collection publique"
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</XStack>
)}
/>
{form.formState.errors.root?.message ? (
<Text color="$danger" variant="caption">
{form.formState.errors.root.message}
</Text>
) : null}
<Button
disabled={!form.formState.isValid}
isLoading={createBookmark.isPending || updateBookmark.isPending}
onPress={form.handleSubmit(handleSubmit)}
>
{bookmark ? "Enregistrer" : "Créer le signet"}
</Button>
</ScrollView>
</BottomSheetModal>
);
}
@@ -0,0 +1,84 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookmarkIcon, CheckIcon } from "lucide-react-native";
import { ScrollView, XStack, YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { BottomSheetModal } from "#mobile/ui/components/bottom-sheet-modal";
import { EmptyState, LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
type BookmarkPickerModalProps = {
articleId: string;
onClose: () => void;
visible: boolean;
};
export function BookmarkPickerModal({ articleId, onClose, visible }: BookmarkPickerModalProps) {
const colors = useAppColors();
const queryClient = useQueryClient();
const trpc = useTRPC();
const bookmarks = useQuery({
...trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }),
enabled: visible,
});
const addArticle = useMutation(
trpc.feed.bookmarks.addArticle.mutationOptions({
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
void queryClient.invalidateQueries({
queryKey: trpc.feed.bookmarks.listArticles.pathKey(),
});
onClose();
},
}),
);
return (
<BottomSheetModal onClose={onClose} title="Ajouter à un signet" visible={visible}>
<YStack gap="$1" paddingBottom="$3" paddingHorizontal="$5">
<Text variant="caption">Choisissez la collection qui recevra cet article.</Text>
</YStack>
{bookmarks.isPending ? <LoadingState /> : null}
{bookmarks.data?.items.length === 0 ? (
<EmptyState
description="Créez dabord un signet depuis longlet Signets."
title="Aucun signet"
/>
) : null}
<ScrollView contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 20 }}>
{bookmarks.data?.items.map((bookmark) => (
<XStack
alignItems="center"
borderBottomColor="$borderColor"
borderBottomWidth={1}
disabled={addArticle.isPending}
gap="$3"
key={bookmark.id}
onPress={() => addArticle.mutate({ articleId, bookmarkId: bookmark.id })}
paddingVertical="$4"
pressStyle={{ opacity: 0.7 }}
>
<YStack
alignItems="center"
backgroundColor="$surface"
borderRadius="$4"
height={44}
justifyContent="center"
width={44}
>
<BookmarkIcon color={colors.primary} size={21} strokeWidth={1.8} />
</YStack>
<YStack flex={1} gap="$1">
<Text variant="title">{bookmark.name}</Text>
<Text variant="caption">{bookmark.articlesCount} articles</Text>
</YStack>
{addArticle.isPending && addArticle.variables?.bookmarkId === bookmark.id ? (
<CheckIcon color={colors.primary} size={20} />
) : null}
</XStack>
))}
</ScrollView>
</BottomSheetModal>
);
}
@@ -0,0 +1,54 @@
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { type InfiniteData, type QueryKey, useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTRPC, useTRPCClient } from "#mobile/application/trpc/client";
import { ARTICLES_PAGE_SIZE } from "#mobile/features/content/articles/hooks/use-infinite-articles";
import { getNextPage } from "#mobile/features/content/shared/get-next-page";
type BookmarkArticlePage = RouterOutputs["feed"]["bookmarks"]["listArticles"];
export function useInfiniteBookmarkArticles(bookmarkId: string) {
const trpc = useTRPC();
const trpcClient = useTRPCClient();
const queryKey = trpc.feed.bookmarks.listArticles.queryKey({
bookmarkId,
limit: ARTICLES_PAGE_SIZE,
});
const query = useInfiniteQuery<
BookmarkArticlePage,
Error,
InfiniteData<BookmarkArticlePage, number>,
QueryKey,
number
>({
enabled: bookmarkId.length > 0,
getNextPageParam: (lastPage) => getNextPage(lastPage.meta),
initialPageParam: 1,
queryFn: async ({ pageParam, signal }): Promise<BookmarkArticlePage> =>
trpcClient.feed.bookmarks.listArticles.query(
{ bookmarkId, limit: ARTICLES_PAGE_SIZE, page: pageParam },
{ signal },
),
queryKey: [...queryKey, "infinite-pages"],
});
const articles = useMemo(() => {
const uniqueArticles = new Map(
query.data?.pages.flatMap((page) => page.items).map((article) => [article.id, article]),
);
return [...uniqueArticles.values()];
}, [query.data?.pages]);
const loadNextPage = useCallback(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage();
}
}, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]);
return {
...query,
articles,
loadNextPage,
total: query.data?.pages[0]?.meta.total ?? 0,
};
}
@@ -0,0 +1,183 @@
import { createCommentSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2Icon } from "lucide-react-native";
import { Controller, useForm } from "react-hook-form";
import { Alert, KeyboardAvoidingView } from "react-native";
import { ScrollView, Separator, XStack, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { useTRPC } from "#mobile/application/trpc/client";
import { formatRelativeTime } from "#mobile/features/content/shared/format-relative-time";
import { BottomSheetModal } from "#mobile/ui/components/bottom-sheet-modal";
import { Button } from "#mobile/ui/components/button";
import { IconButton } from "#mobile/ui/components/icon-button";
import { Input } from "#mobile/ui/components/input";
import { SourceAvatar } from "#mobile/ui/components/source-avatar";
import { ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
const commentFormSchema = createCommentSchema.pick({ content: true });
type CommentForm = z.infer<typeof commentFormSchema>;
type ArticleCommentsProps = {
articleId: string;
enabled: boolean;
};
type ArticleCommentsModalProps = {
articleId: string;
onClose: () => void;
visible: boolean;
};
export function ArticleCommentsModal({ articleId, onClose, visible }: ArticleCommentsModalProps) {
return (
<BottomSheetModal onClose={onClose} title="Commentaires" visible={visible}>
<ArticleComments articleId={articleId} enabled={visible} />
</BottomSheetModal>
);
}
function ArticleComments({ articleId, enabled }: ArticleCommentsProps) {
const colors = useAppColors();
const session = authClient.useSession();
const queryClient = useQueryClient();
const trpc = useTRPC();
const comments = useQuery({
...trpc.feed.comments.list.queryOptions({ articleId, limit: 50, page: 1 }),
enabled,
});
const form = useForm<CommentForm>({
defaultValues: { content: "" },
mode: "onChange",
resolver: zodResolver(commentFormSchema),
});
const createComment = useMutation(
trpc.feed.comments.create.mutationOptions({
onError(error) {
form.setError("root", {
message: error.message || "Impossible de publier ce commentaire.",
});
},
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.comments.list.queryFilter({ articleId }));
form.reset();
},
}),
);
const deleteComment = useMutation(
trpc.feed.comments.delete.mutationOptions({
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.comments.list.queryFilter({ articleId }));
},
}),
);
function handleDelete(id: string) {
Alert.alert("Supprimer le commentaire ?", "Cette action est définitive.", [
{ style: "cancel", text: "Annuler" },
{ onPress: () => deleteComment.mutate({ id }), style: "destructive", text: "Supprimer" },
]);
}
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 20 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<YStack gap="$1">
<Text variant="title">Participer à la discussion</Text>
<Text variant="caption">
{comments.data?.meta.total
? `${comments.data.meta.total} commentaire${comments.data.meta.total > 1 ? "s" : ""}`
: "Soyez la première personne à réagir."}
</Text>
</YStack>
<YStack gap="$3">
<Controller
control={form.control}
name="content"
render={({ field, fieldState }) => (
<Input
error={fieldState.error?.message}
label="Votre commentaire"
multiline
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Partagez votre réaction…"
value={field.value}
/>
)}
/>
{form.formState.errors.root?.message ? (
<Text color="$danger" variant="caption">
{form.formState.errors.root.message}
</Text>
) : null}
<Button
disabled={!form.formState.isValid}
isLoading={createComment.isPending}
onPress={form.handleSubmit((values) => createComment.mutate({ articleId, ...values }))}
>
Publier
</Button>
</YStack>
<Separator />
{comments.isPending ? <LoadingState label="Chargement des commentaires…" /> : null}
{comments.isError ? (
<ErrorState
description="Impossible de charger les commentaires."
onRetry={() => void comments.refetch()}
/>
) : null}
{comments.isSuccess && comments.data.items.length === 0 ? (
<YStack alignItems="center" gap="$1" paddingVertical="$6">
<Text variant="title">Aucun commentaire</Text>
<Text textAlign="center" variant="caption">
Lancez la conversation autour de cet article.
</Text>
</YStack>
) : null}
<YStack gap="$5">
{comments.data?.items.map((comment) => (
<XStack gap="$3" key={comment.id}>
<SourceAvatar name={comment.author.name} size="comment" />
<YStack flex={1} gap="$1.5">
<XStack alignItems="center" gap="$3" justifyContent="space-between">
<XStack alignItems="center" flex={1} gap="$2">
<Text fontWeight="600" numberOfLines={1} variant="caption">
{comment.author.name}
</Text>
<Text variant="caption">{formatRelativeTime(comment.createdAt)}</Text>
</XStack>
{comment.author.id === session.data?.user.id ? (
<IconButton
accessibilityLabel="Supprimer le commentaire"
height={32}
hitSlop={8}
onPress={() => handleDelete(comment.id)}
width={32}
>
<Trash2Icon color={colors.muted} size={16} strokeWidth={1.8} />
</IconButton>
) : null}
</XStack>
<Text>{comment.content}</Text>
</YStack>
</XStack>
))}
</YStack>
</ScrollView>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,6 @@
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export function formatPublicationDate(value: Date): string {
return format(value, "d MMMM yyyy 'à' HH:mm", { locale: fr });
}
@@ -0,0 +1,6 @@
import { formatDistanceToNowStrict } from "date-fns";
import { fr } from "date-fns/locale";
export function formatRelativeTime(value: Date): string {
return formatDistanceToNowStrict(value, { addSuffix: true, locale: fr });
}
@@ -0,0 +1,7 @@
import type { PaginationMeta } from "@basango/domain/models";
type PagePosition = Pick<PaginationMeta, "current" | "hasNext">;
export function getNextPage({ current, hasNext }: PagePosition) {
return hasNext ? current + 1 : undefined;
}
@@ -0,0 +1,12 @@
export function toPlainText(value: string): string {
return value
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]\s)\s*/gm, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/[*_~`]/g, "")
.replace(/<[^>]+>/g, "")
.replace(/\s+/g, " ")
.trim();
}
@@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "#mobile/application/trpc/client";
import type { Source } from "#mobile/features/content/types";
import { Button } from "#mobile/ui/components/button";
type FollowButtonProps = {
presentation?: "compact" | "regular";
source: Pick<Source, "followed" | "id">;
};
export function FollowButton({ presentation = "compact", source }: FollowButtonProps) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const follow = useMutation(
trpc.feed.sources.follow.mutationOptions({
onSuccess: invalidateSources,
}),
);
const unfollow = useMutation(
trpc.feed.sources.unfollow.mutationOptions({
onSuccess: invalidateSources,
}),
);
function invalidateSources() {
void queryClient.invalidateQueries(trpc.feed.sources.list.queryFilter());
void queryClient.invalidateQueries(trpc.feed.sources.get.queryFilter());
}
const action = source.followed ? unfollow : follow;
const isRegular = presentation === "regular";
return (
<Button
flex={isRegular ? 1 : undefined}
height={isRegular ? 44 : 30}
hitSlop={{ bottom: 8, left: 4, right: 4, top: 8 }}
isLoading={action.isPending}
minHeight={isRegular ? 44 : 30}
minWidth={isRegular ? 0 : 80}
onPress={() => action.mutate({ id: source.id })}
paddingHorizontal={isRegular ? "$4" : "$2"}
size={isRegular ? "$4" : "$2"}
variant={source.followed ? "outline" : "primary"}
>
{source.followed ? "Suivi" : "Suivre"}
</Button>
);
}
@@ -0,0 +1,63 @@
import { Link } from "expo-router";
import { StyleSheet } from "react-native";
import { XStack, YStack } from "tamagui";
import { FollowButton } from "#mobile/features/content/sources/components/follow-button";
import type { Source } from "#mobile/features/content/types";
import { SourceAvatar } from "#mobile/ui/components/source-avatar";
import { Text } from "#mobile/ui/components/text";
type SourceCardProps = {
horizontal?: boolean;
showSeparator?: boolean;
source: Source;
};
export function SourceCard({ horizontal = false, showSeparator = true, source }: SourceCardProps) {
if (horizontal) {
return (
<YStack alignItems="center" flexShrink={0} gap="$2" maxWidth={100}>
<Link asChild href={{ params: { id: source.id }, pathname: "/(app)/(tabs)/sources/[id]" }}>
<YStack alignItems="center" gap="$2" pressStyle={{ opacity: 0.72 }}>
<SourceAvatar name={source.displayName ?? source.name} size="large" />
<Text
fontSize="$3"
fontWeight="bold"
maxWidth="100%"
numberOfLines={1}
textAlign="center"
>
{source.displayName ?? source.name}
</Text>
</YStack>
</Link>
<FollowButton source={source} />
</YStack>
);
}
return (
<XStack
alignItems="center"
borderBottomColor="$separator"
borderBottomWidth={showSeparator ? StyleSheet.hairlineWidth : 0}
gap="$4"
paddingVertical="$2"
>
<Link asChild href={{ params: { id: source.id }, pathname: "/(app)/(tabs)/sources/[id]" }}>
<XStack alignItems="center" flex={1} gap="$3" pressStyle={{ opacity: 0.72 }}>
<SourceAvatar name={source.displayName ?? source.name} />
<YStack flex={1} gap="$1">
<Text fontSize="$4" fontWeight="bold" numberOfLines={1}>
{source.displayName ?? source.name}
</Text>
<Text numberOfLines={1} variant="caption">
{source.articlesCount} articles
</Text>
</YStack>
</XStack>
</Link>
<FollowButton source={source} />
</XStack>
);
}
@@ -0,0 +1,8 @@
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
export type ArticleOverview = RouterOutputs["feed"]["articles"]["list"]["items"][number];
export type ArticleDetails = RouterOutputs["feed"]["articles"]["get"];
export type Bookmark = RouterOutputs["feed"]["bookmarks"]["list"]["items"][number];
export type Category = RouterOutputs["feed"]["categories"]["list"][number];
export type Comment = RouterOutputs["feed"]["comments"]["list"]["items"][number];
export type Source = RouterOutputs["feed"]["sources"]["list"]["items"][number];
@@ -0,0 +1,11 @@
export function getAuthErrorMessage(error: unknown, fallback: string): string {
if (typeof error === "object" && error !== null && "message" in error) {
const message = error.message;
if (typeof message === "string" && message.trim()) {
return message;
}
}
return fallback;
}
@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { Modal } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { XStack, YStack } from "tamagui";
import { Button } from "#mobile/ui/components/button";
import { Text } from "#mobile/ui/components/text";
type BottomSheetModalProps = {
children: ReactNode;
onClose: () => void;
title: string;
visible: boolean;
};
export function BottomSheetModal({ children, onClose, title, visible }: BottomSheetModalProps) {
const insets = useSafeAreaInsets();
return (
<Modal
accessibilityViewIsModal
allowSwipeDismissal
animationType="slide"
onRequestClose={onClose}
presentationStyle="pageSheet"
visible={visible}
>
<YStack backgroundColor="$groupedBackground" flex={1} paddingBottom={insets.bottom}>
<XStack
alignItems="center"
backgroundColor="$card"
borderBottomColor="$separator"
borderBottomWidth={0.5}
minHeight={52}
paddingHorizontal="$1"
>
<Button
color="$primary"
minHeight={44}
onPress={onClose}
paddingHorizontal="$3"
variant="ghost"
>
Annuler
</Button>
<Text
fontSize="$5"
fontWeight="600"
left={80}
numberOfLines={1}
position="absolute"
right={80}
textAlign="center"
>
{title}
</Text>
</XStack>
<YStack flex={1} paddingTop="$4">
{children}
</YStack>
</YStack>
</Modal>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { ReactNode } from "react";
import type { GetProps } from "tamagui";
import { Spinner, Button as TamaguiButton, styled } from "tamagui";
const StyledButton = styled(TamaguiButton, {
borderRadius: "$4",
borderWidth: 0,
defaultVariants: {
variant: "primary",
},
fontSize: "$5",
fontWeight: "600",
minHeight: 44,
paddingHorizontal: "$4",
size: "$4",
variants: {
variant: {
destructive: {
backgroundColor: "$danger",
color: "white",
pressStyle: { opacity: 0.82, scale: 0.985 },
},
ghost: {
backgroundColor: "transparent",
color: "$color",
pressStyle: { backgroundColor: "$surface", scale: 0.985 },
},
outline: {
backgroundColor: "$card",
borderColor: "$borderColor",
borderWidth: 1,
color: "$color",
pressStyle: { backgroundColor: "$surface", scale: 0.985 },
},
primary: {
backgroundColor: "$primary",
color: "$primaryForeground",
pressStyle: { opacity: 0.82, scale: 0.985 },
},
secondary: {
backgroundColor: "$surface",
color: "$color",
pressStyle: { opacity: 0.78, scale: 0.985 },
},
},
} as const,
});
type ButtonProps = Omit<GetProps<typeof StyledButton>, "children"> & {
children: ReactNode;
isLoading?: boolean;
};
export function Button({
accessibilityState,
children,
disabled,
isLoading = false,
variant,
...props
}: ButtonProps) {
const isDisabled = disabled || isLoading;
const resolvedVariant = variant ?? "primary";
return (
<StyledButton
accessibilityRole="button"
accessibilityState={{
...accessibilityState,
busy: isLoading,
disabled: Boolean(isDisabled),
}}
disabled={isDisabled}
icon={
isLoading ? (
<Spinner
color={
resolvedVariant === "primary"
? "$primaryForeground"
: resolvedVariant === "destructive"
? "white"
: "$color"
}
/>
) : undefined
}
opacity={isDisabled ? 0.38 : 1}
variant={resolvedVariant}
{...props}
>
{isLoading ? null : children}
</StyledButton>
);
}
@@ -0,0 +1,136 @@
import { ChevronRightIcon } from "lucide-react-native";
import type { ReactNode } from "react";
import { StyleSheet } from "react-native";
import type { GetProps } from "tamagui";
import { XStack, YStack } from "tamagui";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
type GroupedSectionProps = {
children: ReactNode;
footer?: string;
title?: string;
};
export function GroupedSection({ children, footer, title }: GroupedSectionProps) {
return (
<YStack gap="$1.5">
{title ? (
<Text color="$mutedColor" fontSize="$2" marginHorizontal="$4" textTransform="uppercase">
{title}
</Text>
) : null}
<YStack backgroundColor="$card" borderRadius="$5" overflow="hidden">
{children}
</YStack>
{footer ? (
<Text color="$mutedColor" fontSize="$2" marginHorizontal="$4">
{footer}
</Text>
) : null}
</YStack>
);
}
type GroupedRowProps = {
accessibilityHint?: string;
destructive?: boolean;
icon?: ReactNode;
label: string;
onPress?: () => void;
showSeparator?: boolean;
subtitle?: string;
trailing?: ReactNode;
value?: string;
};
export function GroupedRow({
accessibilityHint,
destructive = false,
icon,
label,
onPress,
showSeparator = false,
subtitle,
trailing,
value,
}: GroupedRowProps) {
const colors = useAppColors();
const isCenteredAction = Boolean(onPress && destructive && !icon && !value);
return (
<XStack
accessibilityHint={accessibilityHint}
accessibilityLabel={value ? `${label}, ${value}` : label}
accessibilityRole={onPress ? "button" : undefined}
accessible={Boolean(onPress)}
alignItems="center"
backgroundColor="$card"
gap="$3"
justifyContent={isCenteredAction ? "center" : "flex-start"}
minHeight={52}
onPress={onPress}
paddingHorizontal="$4"
pressStyle={onPress ? { backgroundColor: "$surface" } : undefined}
>
{icon}
<YStack flex={isCenteredAction ? undefined : 1} gap="$0.5" paddingVertical="$2.5">
<Text color={destructive ? "$danger" : "$color"} fontSize={15} numberOfLines={1}>
{label}
</Text>
{subtitle ? (
<Text fontSize="$2" numberOfLines={2} variant="caption">
{subtitle}
</Text>
) : null}
</YStack>
{value ? (
<Text
color="$mutedColor"
flexShrink={1}
fontSize="$4"
maxWidth="54%"
numberOfLines={1}
textAlign="right"
>
{value}
</Text>
) : null}
{trailing}
{onPress && !isCenteredAction && !trailing ? (
<ChevronRightIcon color={colors.muted} size={16} strokeWidth={1.8} />
) : null}
{showSeparator ? (
<YStack
backgroundColor="$separator"
bottom={0}
height={StyleSheet.hairlineWidth}
left={icon ? 60 : 16}
position="absolute"
right={0}
/>
) : null}
</XStack>
);
}
type GroupedIconProps = GetProps<typeof YStack> & {
children: ReactNode;
};
export function GroupedIcon({ children, ...props }: GroupedIconProps) {
return (
<YStack
alignItems="center"
backgroundColor="$primary"
borderRadius="$3"
height={30}
justifyContent="center"
width={30}
{...props}
>
{children}
</YStack>
);
}
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import type { GetProps } from "tamagui";
import { Button as TamaguiButton } from "tamagui";
type IconButtonProps = Omit<GetProps<typeof TamaguiButton>, "children"> & {
accessibilityLabel: string;
children: ReactNode;
};
export function IconButton({ accessibilityLabel, children, ...props }: IconButtonProps) {
return (
<TamaguiButton
accessibilityLabel={accessibilityLabel}
accessibilityLargeContentTitle={accessibilityLabel}
accessibilityRole="button"
accessibilityShowsLargeContentViewer
backgroundColor="transparent"
borderWidth={0}
circular
height="$4"
hitSlop={4}
padding={0}
pressStyle={{ backgroundColor: "$surface", opacity: 0.72, scale: 0.94 }}
size="$4"
width="$4"
{...props}
>
{children}
</TamaguiButton>
);
}
+78
View File
@@ -0,0 +1,78 @@
import type { ReactNode } from "react";
import { useState } from "react";
import type { GetProps } from "tamagui";
import { Input as TamaguiInput, XStack, YStack } from "tamagui";
import { Text } from "#mobile/ui/components/text";
export type InputProps = GetProps<typeof TamaguiInput> & {
error?: string;
label?: string;
trailing?: ReactNode;
};
export function Input({
error,
label,
multiline,
onBlur,
onFocus,
trailing,
...props
}: InputProps) {
const [isFocused, setIsFocused] = useState(false);
const isDisabled = props.disabled === true;
return (
<YStack gap="$1.5">
{label ? (
<Text color="$mutedColor" fontSize="$3" variant="label">
{label}
</Text>
) : null}
<XStack
alignItems="center"
backgroundColor="$surface"
borderColor={error ? "$danger" : isFocused ? "$primary" : "$borderColor"}
borderRadius="$4"
borderWidth={1}
minHeight={multiline ? 96 : 44}
opacity={isDisabled ? 0.45 : 1}
paddingHorizontal="$3"
>
<TamaguiInput
accessibilityLabel={props.accessibilityLabel ?? label}
backgroundColor="transparent"
borderWidth={0}
clearButtonMode={multiline ? "never" : (props.clearButtonMode ?? "while-editing")}
color="$color"
flex={1}
fontFamily="$body"
fontSize={15}
lineHeight={20}
minHeight={multiline ? 94 : 42}
multiline={multiline}
onBlur={(event) => {
setIsFocused(false);
onBlur?.(event);
}}
onFocus={(event) => {
setIsFocused(true);
onFocus?.(event);
}}
paddingHorizontal={0}
paddingVertical={multiline ? "$3" : 0}
placeholderTextColor="$mutedColor"
textAlignVertical={multiline ? "top" : "center"}
{...props}
/>
{trailing}
</XStack>
{error ? (
<Text accessibilityRole="alert" color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
);
}
@@ -0,0 +1,41 @@
import { RssIcon } from "lucide-react-native";
import { YStack } from "tamagui";
type LogoMarkProps = {
size?: "large" | "small";
};
export function LogoMark({ size = "large" }: LogoMarkProps) {
const isLarge = size === "large";
const dimension = isLarge ? 120 : 40;
return (
<YStack
alignItems="center"
backgroundColor="#2581c4"
borderRadius={isLarge ? "$8" : "$3"}
height={dimension}
justifyContent="center"
overflow="hidden"
width={dimension}
>
<YStack
backgroundColor="#d94b55"
bottom={0}
height="29%"
left={0}
position="absolute"
right={0}
/>
<YStack
backgroundColor="#facc15"
bottom="28%"
height={isLarge ? 5 : 2}
left={0}
position="absolute"
right={0}
/>
<RssIcon color="#facc15" size={isLarge ? 58 : 23} strokeWidth={2.5} />
</YStack>
);
}
@@ -0,0 +1,32 @@
import { EyeIcon, EyeOffIcon } from "lucide-react-native";
import { useState } from "react";
import { IconButton } from "#mobile/ui/components/icon-button";
import type { InputProps } from "#mobile/ui/components/input";
import { Input } from "#mobile/ui/components/input";
import { useAppColors } from "#mobile/ui/theme";
export function PasswordInput(props: InputProps) {
const [isVisible, setIsVisible] = useState(false);
const colors = useAppColors();
const VisibilityIcon = isVisible ? EyeOffIcon : EyeIcon;
return (
<Input
autoCapitalize="none"
autoComplete="password"
secureTextEntry={!isVisible}
trailing={
<IconButton
accessibilityLabel={isVisible ? "Masquer le mot de passe" : "Afficher le mot de passe"}
height={40}
onPress={() => setIsVisible((value) => !value)}
width={40}
>
<VisibilityIcon color={colors.muted} size={20} strokeWidth={1.8} />
</IconButton>
}
{...props}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
import { KeyboardAvoidingView, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { GetProps } from "tamagui";
import { YStack } from "tamagui";
type ScreenProps = GetProps<typeof YStack> & {
children: ReactNode;
hasNativeHeader?: boolean;
hasTabBar?: boolean;
};
export function Screen({
backgroundColor = "$background",
children,
hasNativeHeader = false,
hasTabBar = false,
...props
}: ScreenProps) {
const insets = useSafeAreaInsets();
return (
<YStack
{...props}
backgroundColor={backgroundColor}
collapsable={hasNativeHeader ? false : props.collapsable}
flex={1}
paddingBottom={hasTabBar ? 0 : insets.bottom}
paddingTop={hasNativeHeader ? 0 : insets.top}
>
{children}
</YStack>
);
}
type KeyboardScreenProps = ScreenProps;
export function KeyboardScreen({ children, ...props }: KeyboardScreenProps) {
return (
<Screen {...props}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
{children}
</KeyboardAvoidingView>
</Screen>
);
}
@@ -0,0 +1,46 @@
import { ArrowRightIcon } from "lucide-react-native";
import { Button as TamaguiButton, XStack } from "tamagui";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
type SectionHeaderProps = {
actionLabel?: string;
onAction?: () => void;
title: string;
};
export function SectionHeader({ actionLabel = "Voir tout", onAction, title }: SectionHeaderProps) {
const colors = useAppColors();
return (
<XStack
alignItems="center"
gap="$4"
justifyContent="space-between"
paddingVertical="$2"
width="100%"
>
<Text flexShrink={1} fontSize="$6" fontWeight="bold" marginRight="$2" numberOfLines={1}>
{title}
</Text>
{onAction ? (
<TamaguiButton
accessibilityLabel={`${actionLabel} : ${title}`}
accessibilityRole="button"
backgroundColor="transparent"
borderWidth={0}
height={44}
iconAfter={<ArrowRightIcon color={colors.primary} size={20} strokeWidth={1.8} />}
onPress={onAction}
paddingHorizontal={0}
pressStyle={{ opacity: 0.55, scale: 0.98 }}
>
<Text color="$primary" fontWeight="500">
{actionLabel}
</Text>
</TamaguiButton>
) : null}
</XStack>
);
}
@@ -0,0 +1,51 @@
import { YStack } from "tamagui";
import { Text } from "#mobile/ui/components/text";
type SourceAvatarProps = {
name: string;
size?: "comment" | "large" | "medium" | "small";
};
const avatarSizes = {
comment: 32,
large: 65,
medium: 50,
small: 20,
} as const;
const textSizes = {
comment: 12,
large: 18,
medium: 15,
small: 8,
} as const;
export function SourceAvatar({ name, size = "medium" }: SourceAvatarProps) {
const initials = name
.split(/[.\s-]+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0]?.toLocaleUpperCase("fr-CD"))
.join("");
const dimension = avatarSizes[size];
return (
<YStack
accessibilityLabel={name}
accessibilityRole="image"
alignItems="center"
backgroundColor="$surface"
borderColor="$borderColor"
borderRadius={dimension / 2}
borderWidth={1}
height={dimension}
justifyContent="center"
width={dimension}
>
<Text color="$primary" fontSize={textSizes[size]} fontWeight="700">
{initials || "B"}
</Text>
</YStack>
);
}
@@ -0,0 +1,80 @@
import { AlertCircleIcon, InboxIcon } from "lucide-react-native";
import { Spinner, YStack } from "tamagui";
import { Button } from "#mobile/ui/components/button";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
type LoadingStateProps = {
label?: string;
};
export function LoadingState({ label = "Chargement…" }: LoadingStateProps) {
return (
<YStack alignItems="center" flex={1} gap="$3" justifyContent="center" padding="$8">
<Spinner color="$primary" size="small" />
<Text textAlign="center" variant="caption">
{label}
</Text>
</YStack>
);
}
type EmptyStateProps = {
description: string;
title: string;
};
export function EmptyState({ description, title }: EmptyStateProps) {
const colors = useAppColors();
return (
<YStack alignItems="center" flex={1} gap="$3" justifyContent="center" padding="$8">
<YStack
alignItems="center"
backgroundColor="$surface"
borderRadius="$10"
height={48}
justifyContent="center"
width={48}
>
<InboxIcon color={colors.muted} size={23} strokeWidth={1.7} />
</YStack>
<Text textAlign="center" variant="title">
{title}
</Text>
<Text textAlign="center" variant="caption">
{description}
</Text>
</YStack>
);
}
type ErrorStateProps = {
description?: string;
onRetry?: () => void;
};
export function ErrorState({
description = "Une erreur est survenue. Réessayez dans un instant.",
onRetry,
}: ErrorStateProps) {
const colors = useAppColors();
return (
<YStack alignItems="center" flex={1} gap="$3" justifyContent="center" padding="$8">
<AlertCircleIcon color={colors.muted} size={28} strokeWidth={1.7} />
<Text textAlign="center" variant="title">
Impossible de charger
</Text>
<Text textAlign="center" variant="caption">
{description}
</Text>
{onRetry ? (
<Button onPress={onRetry} variant="outline">
Réessayer
</Button>
) : null}
</YStack>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { SwitchProps as NativeSwitchProps } from "react-native";
import { Switch as NativeSwitch } from "react-native";
import { useAppColors } from "#mobile/ui/theme";
type SwitchProps = Omit<NativeSwitchProps, "onValueChange" | "value"> & {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
};
export function Switch({ checked, onCheckedChange, ...props }: SwitchProps) {
const colors = useAppColors();
return (
<NativeSwitch
ios_backgroundColor={colors.border}
onValueChange={onCheckedChange}
trackColor={{ false: colors.border, true: colors.primary }}
value={checked}
{...props}
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ParagraphProps } from "tamagui";
import { H2, H4, Paragraph } from "tamagui";
export type TextProps = ParagraphProps & {
variant?: "body" | "caption" | "display" | "heading" | "label" | "title";
};
export function Text({ variant = "body", ...props }: TextProps) {
switch (variant) {
case "caption":
return <Paragraph color="$mutedColor" fontSize="$2" lineHeight="$1" {...props} />;
case "display":
return <H2 fontWeight="bold" lineHeight="$8" {...props} />;
case "heading":
return <H4 alignSelf="flex-start" fontWeight="bold" {...props} />;
case "label":
return <Paragraph fontWeight="600" {...props} />;
case "title":
return <Paragraph fontSize="$5" fontWeight="600" {...props} />;
default:
return <Paragraph {...props} />;
}
}
+4
View File
@@ -0,0 +1,4 @@
export const screenBottomPadding = 40;
export const screenGutter = 16;
export const sectionGap = 20;
export const sheetGutter = 20;

Some files were not shown because too many files have changed in this diff Show More