feat: mobile application init
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 |
|
After Width: | Height: | Size: 52 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 780 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 215 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 468 B |
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 343 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,3 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
module.exports = getDefaultConfig(__dirname);
|
||||
@@ -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 · L’actualité 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 l’article…" />
|
||||
</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 l’article" 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 l’article
|
||||
</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 l’actualité…" />
|
||||
) : 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 l’article ?", "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 l’icône de signet pour l’ajouter 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AppLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -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 d’envoyer 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>
|
||||
);
|
||||
}
|
||||
@@ -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 d’actualité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 d’utilisation de Basango et reconnaissez
|
||||
avoir lu notre politique de confidentialité.
|
||||
</Text>
|
||||
|
||||
<Link asChild href="/(auth)/sign-up">
|
||||
<Text>Vous n’avez 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>
|
||||
);
|
||||
}
|
||||
@@ -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 d’utilisation 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>
|
||||
);
|
||||
}
|
||||
@@ -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 d’actualités intelligente qui vous aide à rester informé sur
|
||||
l’actualité 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 d’utilisation de Basango et reconnaissez avoir
|
||||
lu notre politique de confidentialité.
|
||||
</Text>
|
||||
</YStack>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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 n’existe pas
|
||||
</Text>
|
||||
<Text textAlign="center" variant="caption">
|
||||
Revenez aux actualités pour continuer.
|
||||
</Text>
|
||||
<Button onPress={() => router.replace("/")}>Retour à l’accueil</Button>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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 d’au 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 d’abord un signet depuis l’onglet 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const screenBottomPadding = 40;
|
||||
export const screenGutter = 16;
|
||||
export const sectionGap = 20;
|
||||
export const sheetGutter = 20;
|
||||