feat: mobile application MVP
Quality Assurance / lint (push) Successful in 1m25s
Quality Assurance / typecheck (push) Successful in 1m25s
Quality Assurance / typecheck (push) Successful in 1m23s
Quality Assurance / lint (push) Successful in 1m24s
Deploy / quality (push) Successful in 1m25s
Deploy / deploy (push) Failing after 4s

This commit is contained in:
2026-08-29 21:30:10 +02:00
parent 464f6ec1c6
commit 8dbb7fe16c
91 changed files with 4647 additions and 1916 deletions
+26 -1
View File
@@ -1,7 +1,32 @@
import { getCategories } from "@basango/db/queries";
import {
createCategory,
deleteCategory,
getCategories,
getClusteringStats,
updateCategory,
} from "@basango/db/queries";
import {
createCategorySchema,
deleteCategorySchema,
updateCategorySchema,
} from "@basango/domain/models";
import { adminProcedure, createTRPCRouter } from "#api/trpc/init";
export const categoriesRouter = createTRPCRouter({
create: adminProcedure.input(createCategorySchema).mutation(async ({ ctx, input }) => {
return createCategory(ctx.db, input);
}),
delete: adminProcedure.input(deleteCategorySchema).mutation(async ({ ctx, input }) => {
return deleteCategory(ctx.db, input.id);
}),
list: adminProcedure.query(async ({ ctx }) => getCategories(ctx.db)),
stats: adminProcedure.query(async ({ ctx }) => getClusteringStats(ctx.db)),
update: adminProcedure.input(updateCategorySchema).mutation(async ({ ctx, input }) => {
return updateCategory(ctx.db, input);
}),
});
+5
View File
@@ -5,6 +5,7 @@ import {
deleteReaderBookmark,
deleteReaderComment,
followReaderSource,
getReaderArticleBookmarkMemberships,
getReaderArticleById,
getReaderArticles,
getReaderBookmarkArticles,
@@ -73,6 +74,10 @@ const feedBookmarksRouter = createTRPCRouter({
return getReaderBookmarkArticles(ctx.db, ctx.session.user.id, input);
}),
memberships: protectedProcedure.input(readerArticleSchema).query(async ({ ctx, input }) => {
return getReaderArticleBookmarkMemberships(ctx.db, ctx.session.user.id, input.id);
}),
removeArticle: protectedProcedure
.input(bookmarkArticleSchema)
.mutation(async ({ ctx, input }) => {
+2 -1
View File
@@ -51,5 +51,6 @@
"start": "NODE_ENV=production node .output/server/index.mjs",
"typecheck": "tsc --noEmit"
},
"type": "module"
"type": "module",
"version": "1.0.0"
}
@@ -0,0 +1,34 @@
import { Card } from "@basango/ui/components/card";
import type { LucideIcon } from "lucide-react";
type MetricCardProps = {
detail: string;
icon: LucideIcon;
label: string;
suffix?: string;
tone: "danger" | "healthy" | "neutral" | "warning";
value: number;
};
export function MetricCard({ detail, icon: Icon, label, suffix, tone, value }: MetricCardProps) {
const toneClass = {
danger: "text-destructive",
healthy: "text-emerald-600 dark:text-emerald-400",
neutral: "text-foreground",
warning: "text-amber-600 dark:text-amber-400",
}[tone];
return (
<Card className="flex flex-col gap-2 border-border bg-card p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-4xl font-semibold tabular-nums">
{value.toLocaleString()}
{suffix}
</p>
<div className="flex items-center gap-2 text-sm font-medium">
<Icon className={`size-4 ${toneClass}`} />
<span className="text-muted-foreground">{detail}</span>
</div>
</Card>
);
}
@@ -25,9 +25,14 @@ const navigationGroups = [
items: [
{ title: "Sources", url: "/sources" },
{ title: "Articles", url: "/articles" },
{ title: "Categories", url: "/categories" },
],
title: "Content",
},
{
items: [{ title: "Users", url: "/users" }],
title: "Access",
},
] as const;
export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
+3 -1
View File
@@ -1,3 +1,5 @@
import dashboardPackage from "../../package.json";
export function getPublicApiUrl() {
return (
import.meta.env.VITE_PUBLIC_API_URL ??
@@ -7,7 +9,7 @@ export function getPublicApiUrl() {
}
export function getPublicVersion() {
return import.meta.env.VITE_PUBLIC_VERSION ?? process.env.VITE_PUBLIC_VERSION ?? "0.0.0";
return dashboardPackage.version;
}
export function getUrl() {
@@ -0,0 +1,65 @@
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Badge } from "@basango/ui/components/badge";
import type { ColumnDef } from "@tanstack/react-table";
import { formatNumber } from "#dashboard/app/utils/formatters";
import { type CategoryRowAction, CategoryRowActions } from "./category-row-actions";
export type ManagedCategory = RouterOutputs["categories"]["list"][number];
type CreateCategoriesColumnsOptions = {
onAction: (action: CategoryRowAction, category: ManagedCategory) => void;
};
export function createCategoriesColumns({
onAction,
}: CreateCategoriesColumnsOptions): ColumnDef<ManagedCategory>[] {
return [
{
accessorKey: "name",
cell: ({ row }) => (
<div className="grid min-w-48 gap-0.5">
<span className="truncate font-medium">{row.original.name}</span>
<span className="text-muted-foreground truncate font-mono text-xs">
{row.original.slug}
</span>
</div>
),
header: "Category",
},
{
accessorKey: "candidates",
cell: ({ row }) => (
<div className="flex min-w-64 max-w-xl flex-wrap gap-1.5">
{row.original.candidates.map((candidate) => (
<Badge key={candidate} variant="secondary">
{candidate}
</Badge>
))}
</div>
),
enableSorting: false,
header: "Candidate labels",
},
{
accessorKey: "weight",
cell: ({ row }) => <span className="tabular-nums">{row.original.weight}</span>,
header: "Weight",
},
{
accessorKey: "articleCount",
cell: ({ row }) => (
<span className="tabular-nums">{formatNumber(row.original.articleCount)}</span>
),
header: "Assigned",
},
{
cell: ({ row }) => <CategoryRowActions category={row.original} onAction={onAction} />,
enableHiding: false,
enableSorting: false,
header: () => <span className="sr-only">Actions</span>,
id: "actions",
},
];
}
@@ -0,0 +1,91 @@
"use client";
import { DataTable, DataTableToolbar } from "@basango/ui/components/data-table";
import { useTableStore } from "@basango/ui/stores/table-store";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import { type ManagedCategory, createCategoriesColumns } from "./categories-columns";
import type { CategoryRowAction } from "./category-row-actions";
const TABLE_ID = "content.categories";
type CategoriesTableProps = {
categories: ManagedCategory[];
isLoading: boolean;
onAction: (action: CategoryRowAction, category: ManagedCategory) => void;
};
export function CategoriesTable({ categories, isLoading, onAction }: CategoriesTableProps) {
const store = useTableStore(TABLE_ID, {
pagination: { pageIndex: 0, pageSize: 10 },
sorting: [{ desc: true, id: "weight" }],
});
const columns = useMemo<ColumnDef<ManagedCategory>[]>(
() => createCategoriesColumns({ onAction }),
[onAction],
);
const filteredCategories = filterCategories(categories, store.globalFilter);
const sortedCategories = sortCategories(filteredCategories, store.sorting[0]);
const pageCount = Math.max(1, Math.ceil(sortedCategories.length / store.pagination.pageSize));
const pageIndex = Math.min(store.pagination.pageIndex, pageCount - 1);
const offset = pageIndex * store.pagination.pageSize;
const visibleCategories = sortedCategories.slice(offset, offset + store.pagination.pageSize);
return (
<DataTable
columns={columns}
data={visibleCategories}
emptyDescription="Try another search or create a category."
emptyTitle="No categories found"
getRowId={(category) => category.id}
height="h-[34rem]"
loading={isLoading}
pageCount={pageCount}
rowCount={sortedCategories.length}
tableId={TABLE_ID}
toolbar={({ store: tableStore, table }) => (
<DataTableToolbar
filterPlaceholder="Search categories or candidate labels…"
store={tableStore}
table={table}
/>
)}
/>
);
}
function filterCategories(categories: readonly ManagedCategory[], query: string) {
const normalizedQuery = query.trim().toLocaleLowerCase("fr-CD");
if (!normalizedQuery) {
return [...categories];
}
return categories.filter((category) => {
const searchable = [category.name, category.slug, ...category.candidates]
.join(" ")
.toLocaleLowerCase("fr-CD");
return searchable.includes(normalizedQuery);
});
}
function sortCategories(
categories: readonly ManagedCategory[],
sorting: { desc: boolean; id: string } | undefined,
) {
if (!sorting) {
return [...categories];
}
const direction = sorting.desc ? -1 : 1;
return [...categories].sort((left, right) => {
if (sorting.id === "weight" || sorting.id === "articleCount") {
return (left[sorting.id] - right[sorting.id]) * direction;
}
return left.name.localeCompare(right.name, "fr-CD") * direction;
});
}
@@ -0,0 +1,43 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Button } from "@basango/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@basango/ui/components/dropdown-menu";
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
type ManagedCategory = RouterOutputs["categories"]["list"][number];
export type CategoryRowAction = "delete" | "edit";
type CategoryRowActionsProps = {
category: ManagedCategory;
onAction: (action: CategoryRowAction, category: ManagedCategory) => void;
};
export function CategoryRowActions({ category, onAction }: CategoryRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="icon-sm" variant="ghost" />}>
<MoreHorizontalIcon />
<span className="sr-only">Actions for {category.name}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAction("edit", category)}>
<PencilIcon />
Edit category
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction("delete", category)} variant="destructive">
<Trash2Icon />
Delete category
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,145 @@
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Badge } from "@basango/ui/components/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@basango/ui/components/chart";
import {
ChartNoAxesColumnIncreasingIcon,
CircleCheckIcon,
CircleHelpIcon,
ClockIcon,
} from "lucide-react";
import { MetricCard } from "#dashboard/app/components/metric-card";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
XAxis,
YAxis,
} from "#dashboard/app/components/recharts";
import { formatNumber } from "#dashboard/app/utils/formatters";
import { getColorFromName } from "#dashboard/features/content/shared/utils/category-colors";
type ClusteringStatsData = RouterOutputs["categories"]["stats"];
type ClusteringStatsProps = {
stats: ClusteringStatsData;
};
const chartConfig = {
articleCount: {
color: "var(--chart-1)",
label: "Articles",
},
} satisfies ChartConfig;
export function ClusteringStats({ stats }: ClusteringStatsProps) {
const distribution = stats.categories.slice(0, 10);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
detail={`${formatNumber(stats.clustered)} of ${formatNumber(stats.total)} articles`}
icon={ChartNoAxesColumnIncreasingIcon}
label="Clustering coverage"
suffix="%"
tone={stats.clusteringPercent >= 95 ? "healthy" : "warning"}
value={stats.clusteringPercent}
/>
<MetricCard
detail={`${formatNumber(stats.total)} collected articles`}
icon={CircleCheckIcon}
label="Clustered articles"
tone="healthy"
value={stats.clustered}
/>
<MetricCard
detail="Waiting for the clustering worker"
icon={ClockIcon}
label="Pending clustering"
tone={stats.pending > 0 ? "warning" : "healthy"}
value={stats.pending}
/>
<MetricCard
detail={`${formatNumber(stats.unassigned)} currently unassigned`}
icon={CircleHelpIcon}
label="Fallback assignments"
tone={stats.fallbackAssignments > 0 ? "warning" : "neutral"}
value={stats.fallbackAssignments}
/>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]">
<Card className="pt-0">
<CardHeader className="border-b py-5">
<CardTitle>Assignment distribution</CardTitle>
<CardDescription>Articles assigned to the ten largest categories</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="h-[320px] w-full" config={chartConfig}>
<BarChart accessibilityLayer data={distribution} layout="vertical">
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis allowDecimals={false} axisLine={false} tickLine={false} type="number" />
<YAxis
axisLine={false}
dataKey="name"
tickLine={false}
tickMargin={8}
type="category"
width={150}
/>
<ChartTooltip
content={<ChartTooltipContent nameKey="name" />}
cursor={{ fill: "var(--muted)", opacity: 0.4 }}
/>
<Bar dataKey="articleCount" radius={[0, 4, 4, 0]}>
{distribution.map((category) => (
<Cell fill={getColorFromName(category.name)} key={category.id} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="pt-0">
<CardHeader className="border-b py-5">
<CardTitle>Unmatched source labels</CardTitle>
<CardDescription>Frequent labels not covered by a candidate</CardDescription>
</CardHeader>
<CardContent className="pt-6">
{stats.unknownCandidates.length > 0 ? (
<div className="flex flex-wrap gap-2">
{stats.unknownCandidates.map((candidate) => (
<Badge key={candidate.candidate} variant="outline">
{candidate.candidate} · {formatNumber(candidate.count)}
</Badge>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">
Every observed source label is covered by a category candidate.
</p>
)}
<p className="text-muted-foreground mt-4 text-xs">
Add frequent unmatched labels as candidates to improve future classification.
</p>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@basango/ui/components/alert-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTRPC } from "#dashboard/app/trpc/client";
type ManagedCategory = RouterOutputs["categories"]["list"][number];
type CategoryDeleteDialogProps = {
category: ManagedCategory;
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function CategoryDeleteDialog({ category, onOpenChange, open }: CategoryDeleteDialogProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const deleteCategory = useMutation(
trpc.categories.delete.mutationOptions({
onError(error) {
toast.error(error.message || "Unable to delete category.");
},
onSuccess() {
toast.success("Category deleted. Articles are queued for clustering again.");
void queryClient.invalidateQueries({ queryKey: trpc.categories.list.queryKey() });
void queryClient.invalidateQueries({ queryKey: trpc.categories.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: trpc.articles.list.queryKey() });
onOpenChange(false);
},
}),
);
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {category.name}?</AlertDialogTitle>
<AlertDialogDescription>
Its {category.articleCount} assigned articles will be reclassified using the remaining
categories. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteCategory.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deleteCategory.isPending}
onClick={() => deleteCategory.mutate({ id: category.id })}
variant="destructive"
>
{deleteCategory.isPending ? "Deleting…" : "Delete category"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,42 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@basango/ui/components/dialog";
import { CategoryForm } from "../forms/category-form";
type ManagedCategory = RouterOutputs["categories"]["list"][number];
type CategoryDialogProps = {
category?: ManagedCategory;
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function CategoryDialog({ category, onOpenChange, open }: CategoryDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-h-[calc(100svh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{category ? "Edit category" : "Create category"}</DialogTitle>
<DialogDescription>
{category
? "Rename the category or tune the source labels used to classify articles."
: "Add a destination for article clustering and its matching source labels."}
</DialogDescription>
</DialogHeader>
<CategoryForm
category={category}
key={category?.id ?? "new-category"}
onSuccess={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,207 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { updateCategorySchema } from "@basango/domain/models";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@basango/ui/components/field";
import { Input } from "@basango/ui/components/input";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { Textarea } from "@basango/ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useZodForm } from "#dashboard/app/hooks/use-zod-form";
import { useTRPC } from "#dashboard/app/trpc/client";
const categoryFieldsSchema = updateCategorySchema.omit({ id: true });
const categoryFormSchema = z.object({
candidates: z.string().trim().min(1, "Add at least one candidate label."),
description: z
.string()
.optional()
.transform((value) => value?.trim() || undefined)
.pipe(categoryFieldsSchema.shape.description),
name: z.string().trim().pipe(categoryFieldsSchema.shape.name),
slug: z.string().trim().pipe(categoryFieldsSchema.shape.slug),
weight: categoryFieldsSchema.shape.weight,
});
type CategoryFormValues = z.infer<typeof categoryFormSchema>;
type ManagedCategory = RouterOutputs["categories"]["list"][number];
type CategoryFormProps = {
category?: ManagedCategory;
onSuccess: () => void;
};
export function CategoryForm({ category, onSuccess }: CategoryFormProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const form = useZodForm(categoryFormSchema, {
defaultValues: {
candidates: category?.candidates.join(", ") ?? "",
description: category?.description ?? "",
name: category?.name ?? "",
slug: category?.slug ?? "",
weight: category?.weight ?? 1,
},
});
function refreshCategories() {
void queryClient.invalidateQueries({ queryKey: trpc.categories.list.queryKey() });
void queryClient.invalidateQueries({ queryKey: trpc.categories.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: trpc.articles.list.queryKey() });
}
const createCategory = useMutation(
trpc.categories.create.mutationOptions({
onError(error) {
toast.error(error.message || "Unable to create category.");
},
onSuccess() {
toast.success("Category created. Existing articles are queued for clustering.");
refreshCategories();
onSuccess();
},
}),
);
const updateCategory = useMutation(
trpc.categories.update.mutationOptions({
onError(error) {
toast.error(error.message || "Unable to update category.");
},
onSuccess() {
toast.success("Category updated.");
refreshCategories();
onSuccess();
},
}),
);
const isPending = createCategory.isPending || updateCategory.isPending;
function handleSubmit(values: CategoryFormValues) {
const candidates = values.candidates
.split(/[\n,]/)
.map((candidate) => candidate.trim())
.filter(Boolean);
if (category) {
updateCategory.mutate({ ...values, candidates, id: category.id });
return;
}
createCategory.mutate({ ...values, candidates });
}
return (
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
disabled={isPending}
id={field.name}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<Controller
control={form.control}
name="slug"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Slug</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
disabled={isPending}
id={field.name}
placeholder="politique-gouvernement"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="weight"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Weight</FieldLabel>
<Input
aria-invalid={fieldState.invalid}
disabled={isPending}
id={field.name}
max={100}
min={0}
name={field.name}
onBlur={field.onBlur}
onChange={(event) => field.onChange(Number(event.target.value))}
ref={field.ref}
type="number"
value={field.value}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
<Controller
control={form.control}
name="candidates"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Candidate labels</FieldLabel>
<Textarea
{...field}
aria-invalid={fieldState.invalid}
disabled={isPending}
id={field.name}
placeholder="politique, élections, parlement"
rows={5}
/>
<FieldDescription>
Separate labels with commas or new lines. They are matched without accents or case.
</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
<Textarea {...field} disabled={isPending} id={field.name} rows={3} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
<SubmitButton className="w-full" isSubmitting={isPending} type="submit">
{category ? "Save category" : "Create category"}
</SubmitButton>
</form>
);
}
@@ -0,0 +1,100 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Alert, AlertDescription, AlertTitle } from "@basango/ui/components/alert";
import { Button } from "@basango/ui/components/button";
import { useQuery } from "@tanstack/react-query";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { PageLayout } from "#dashboard/app/components/page-layout";
import { useTRPC } from "#dashboard/app/trpc/client";
import { CategoriesTable } from "../components/categories-table";
import type { CategoryRowAction } from "../components/category-row-actions";
import { ClusteringStats } from "../components/clustering-stats";
import { CategoryDeleteDialog } from "../dialogs/category-delete-dialog";
import { CategoryDialog } from "../dialogs/category-dialog";
type ManagedCategory = RouterOutputs["categories"]["list"][number];
export function CategoriesPage() {
const trpc = useTRPC();
const categories = useQuery(trpc.categories.list.queryOptions());
const stats = useQuery(trpc.categories.stats.queryOptions());
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<ManagedCategory>();
const [deletingCategory, setDeletingCategory] = useState<ManagedCategory>();
const error = categories.error ?? stats.error;
function handleCategoryAction(action: CategoryRowAction, category: ManagedCategory) {
if (action === "edit") {
setEditingCategory(category);
return;
}
setDeletingCategory(category);
}
return (
<PageLayout
actions={
<Button onClick={() => setIsCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Add category
</Button>
}
description="Manage article categories, tune matching candidates, and monitor clustering quality."
title="Categories"
>
{error ? (
<Alert variant="destructive">
<AlertTitle>Unable to load category management</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
) : null}
{stats.data ? <ClusteringStats stats={stats.data} /> : null}
<section className="space-y-4">
<div>
<h2 className="font-semibold text-lg">Category rules</h2>
<p className="text-muted-foreground text-sm">
Higher weights win when one article matches candidates from multiple categories.
</p>
</div>
<CategoriesTable
categories={categories.data ?? []}
isLoading={categories.isPending}
onAction={handleCategoryAction}
/>
</section>
<CategoryDialog onOpenChange={setIsCreateOpen} open={isCreateOpen} />
{editingCategory ? (
<CategoryDialog
category={editingCategory}
onOpenChange={(open) => {
if (!open) {
setEditingCategory(undefined);
}
}}
open
/>
) : null}
{deletingCategory ? (
<CategoryDeleteDialog
category={deletingCategory}
onOpenChange={(open) => {
if (!open) {
setDeletingCategory(undefined);
}
}}
open
/>
) : null}
</PageLayout>
);
}
@@ -0,0 +1,78 @@
"use client";
import { Button } from "@basango/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@basango/ui/components/dropdown-menu";
import {
BanIcon,
KeyRoundIcon,
LogOutIcon,
MoreHorizontalIcon,
PencilIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react";
import type { ManagedUser } from "../managed-users";
export type UserRowAction = "ban" | "delete" | "edit" | "password" | "revoke" | "unban";
type UserRowActionsProps = {
isCurrentUser: boolean;
onAction: (action: UserRowAction, user: ManagedUser) => void;
user: ManagedUser;
};
export function UserRowActions({ isCurrentUser, onAction, user }: UserRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="icon-sm" variant="ghost" />}>
<MoreHorizontalIcon />
<span className="sr-only">Actions for {user.name}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAction("edit", user)}>
<PencilIcon />
Edit user
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("password", user)}>
<KeyRoundIcon />
Set password
</DropdownMenuItem>
<DropdownMenuItem disabled={isCurrentUser} onClick={() => onAction("revoke", user)}>
<LogOutIcon />
Revoke sessions
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.banned ? (
<DropdownMenuItem onClick={() => onAction("unban", user)}>
<ShieldCheckIcon />
Unban account
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={isCurrentUser}
onClick={() => onAction("ban", user)}
variant="destructive"
>
<BanIcon />
Ban account
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={isCurrentUser}
onClick={() => onAction("delete", user)}
variant="destructive"
>
<Trash2Icon />
Delete user
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,120 @@
import { Avatar, AvatarFallback, AvatarImage } from "@basango/ui/components/avatar";
import { Badge } from "@basango/ui/components/badge";
import type { ColumnDef } from "@tanstack/react-table";
import type { ManagedUser } from "../managed-users";
import { type UserRowAction, UserRowActions } from "./user-row-actions";
const dateFormatter = new Intl.DateTimeFormat("en", {
dateStyle: "medium",
});
type CreateUsersColumnsOptions = {
currentUserId?: string;
onAction: (action: UserRowAction, user: ManagedUser) => void;
};
export function createUsersColumns({
currentUserId,
onAction,
}: CreateUsersColumnsOptions): ColumnDef<ManagedUser>[] {
return [
{
accessorKey: "name",
cell: ({ row }) => {
const isCurrentUser = row.original.id === currentUserId;
return (
<div className="flex items-center gap-3">
<Avatar className="size-9">
<AvatarImage alt="" src={row.original.image ?? undefined} />
<AvatarFallback>{getInitials(row.original.name)}</AvatarFallback>
</Avatar>
<div className="grid min-w-0 gap-0.5">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{row.original.name}</span>
{isCurrentUser ? <Badge variant="outline">You</Badge> : null}
</div>
<span className="truncate text-xs text-muted-foreground">{row.original.email}</span>
</div>
</div>
);
},
header: "User",
},
{
accessorKey: "role",
cell: ({ row }) => {
const isAdmin = row.original.role?.split(",").includes("admin");
return (
<Badge variant={isAdmin ? "default" : "secondary"}>{isAdmin ? "Admin" : "User"}</Badge>
);
},
enableSorting: false,
header: "Role",
},
{
accessorKey: "banned",
cell: ({ row }) => <AccountStatus user={row.original} />,
enableSorting: false,
header: "Status",
},
{
accessorKey: "createdAt",
cell: ({ row }) => (
<time
className="text-muted-foreground"
dateTime={new Date(row.original.createdAt).toISOString()}
>
{dateFormatter.format(new Date(row.original.createdAt))}
</time>
),
header: "Created",
},
{
cell: ({ row }) => (
<UserRowActions
isCurrentUser={row.original.id === currentUserId}
onAction={onAction}
user={row.original}
/>
),
enableHiding: false,
enableSorting: false,
header: () => <span className="sr-only">Actions</span>,
id: "actions",
},
];
}
function AccountStatus({ user }: { user: ManagedUser }) {
if (!user.banned) {
return <Badge variant="secondary">Active</Badge>;
}
const expiresAt = user.banExpires ? new Date(user.banExpires) : undefined;
const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
return (
<div className="grid gap-1">
<Badge variant={isExpired ? "outline" : "destructive"}>
{isExpired ? "Ban expired" : "Banned"}
</Badge>
{user.banReason ? (
<span className="max-w-48 truncate text-xs text-muted-foreground" title={user.banReason}>
{user.banReason}
</span>
) : null}
</div>
);
}
function getInitials(name: string) {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
@@ -0,0 +1,80 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@basango/ui/components/alert";
import { Button } from "@basango/ui/components/button";
import { DataTable, DataTableToolbar } from "@basango/ui/components/data-table";
import { useTableStore } from "@basango/ui/stores/table-store";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import { type ManagedUser, managedUsersQueryOptions } from "../managed-users";
import type { UserRowAction } from "./user-row-actions";
import { createUsersColumns } from "./users-columns";
const TABLE_ID = "identity.users";
type UsersTableProps = {
currentUserId?: string;
onAction: (action: UserRowAction, user: ManagedUser) => void;
};
export function UsersTable({ currentUserId, onAction }: UsersTableProps) {
const store = useTableStore(TABLE_ID, {
pagination: { pageIndex: 0, pageSize: 10 },
sorting: [{ desc: true, id: "createdAt" }],
});
const sort = store.sorting[0];
const users = useQuery({
...managedUsersQueryOptions({
limit: store.pagination.pageSize,
page: store.pagination.pageIndex + 1,
search: store.globalFilter,
sortBy: sort?.id,
sortDirection: sort?.desc ? "desc" : "asc",
}),
enabled: typeof document !== "undefined",
placeholderData: keepPreviousData,
});
const columns = useMemo<ColumnDef<ManagedUser>[]>(
() => createUsersColumns({ currentUserId, onAction }),
[currentUserId, onAction],
);
const pageCount = Math.max(1, Math.ceil((users.data?.total ?? 0) / store.pagination.pageSize));
return (
<div className="space-y-4">
{users.isError ? (
<Alert variant="destructive">
<AlertTitle>Unable to load users</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-3">
<span>{users.error.message}</span>
<Button onClick={() => users.refetch()} size="sm" type="button" variant="outline">
Retry
</Button>
</AlertDescription>
</Alert>
) : null}
<DataTable
columns={columns}
data={users.data?.users ?? []}
emptyDescription="Try another search or create a new account."
emptyTitle="No users found"
getRowId={(user) => user.id}
height="h-[34rem]"
loading={users.isPending}
pageCount={pageCount}
rowCount={users.data?.total}
tableId={TABLE_ID}
toolbar={({ store: tableStore, table }) => (
<DataTableToolbar
filterPlaceholder="Search users by email…"
store={tableStore}
table={table}
/>
)}
/>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@basango/ui/components/dialog";
import { Field, FieldError, FieldGroup, FieldLabel } from "@basango/ui/components/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@basango/ui/components/select";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { Textarea } from "@basango/ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { useZodForm } from "#dashboard/app/hooks/use-zod-form";
import { type ManagedUser, banManagedUser, managedUsersQueryKey } from "../managed-users";
import {
type BanUserFormValues,
banUserFormSchema,
getBanDurationSeconds,
} from "../user-form-schema";
type UserBanDialogProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
user: ManagedUser;
};
export function UserBanDialog({ onOpenChange, open, user }: UserBanDialogProps) {
const queryClient = useQueryClient();
const form = useZodForm(banUserFormSchema, {
defaultValues: {
banReason: "",
duration: "permanent",
},
});
const banUser = useMutation({
mutationFn: banManagedUser,
onError(error) {
toast.error(error.message);
},
onSuccess() {
toast.success("Account banned and active sessions revoked.");
void queryClient.invalidateQueries({ queryKey: managedUsersQueryKey() });
form.reset();
onOpenChange(false);
},
});
function handleSubmit(values: BanUserFormValues) {
banUser.mutate({
banExpiresIn: getBanDurationSeconds(values.duration),
banReason: values.banReason,
userId: user.id,
});
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban {user.name}?</DialogTitle>
<DialogDescription>
The user will be signed out everywhere and blocked from signing in until the ban
expires.
</DialogDescription>
</DialogHeader>
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="banReason"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Reason</FieldLabel>
<Textarea
{...field}
disabled={banUser.isPending}
id={field.name}
placeholder="Explain why this account is being banned"
rows={4}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="duration"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Duration</FieldLabel>
<Select
disabled={banUser.isPending}
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger id={field.name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">24 hours</SelectItem>
<SelectItem value="week">7 days</SelectItem>
<SelectItem value="month">30 days</SelectItem>
<SelectItem value="permanent">Permanent</SelectItem>
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
<SubmitButton className="w-full" isSubmitting={banUser.isPending} type="submit">
Ban account
</SubmitButton>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,109 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@basango/ui/components/alert-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
type ManagedUser,
deleteManagedUser,
managedUsersQueryKey,
revokeManagedUserSessions,
unbanManagedUser,
} from "../managed-users";
export type UserConfirmAction = "delete" | "revoke" | "unban";
const actionContent = {
delete: {
confirmLabel: "Delete user",
description: "This permanently deletes the user, their login methods, and all active sessions.",
successMessage: "User deleted.",
title: "Delete this user?",
variant: "destructive",
},
revoke: {
confirmLabel: "Revoke sessions",
description: "The user will be signed out on every device and must sign in again.",
successMessage: "All user sessions revoked.",
title: "Sign this user out everywhere?",
variant: "default",
},
unban: {
confirmLabel: "Unban account",
description: "The user will be able to sign in again immediately.",
successMessage: "Account unbanned.",
title: "Unban this account?",
variant: "default",
},
} as const;
type UserConfirmActionDialogProps = {
action: UserConfirmAction;
onOpenChange: (open: boolean) => void;
open: boolean;
user: ManagedUser;
};
export function UserConfirmActionDialog({
action,
onOpenChange,
open,
user,
}: UserConfirmActionDialogProps) {
const queryClient = useQueryClient();
const content = actionContent[action];
const performAction = useMutation({
mutationFn: async () => {
if (action === "delete") {
return deleteManagedUser(user.id);
}
if (action === "revoke") {
return revokeManagedUserSessions(user.id);
}
return unbanManagedUser(user.id);
},
onError(error) {
toast.error(error.message);
},
onSuccess() {
toast.success(content.successMessage);
void queryClient.invalidateQueries({ queryKey: managedUsersQueryKey() });
onOpenChange(false);
},
});
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{content.title}</AlertDialogTitle>
<AlertDialogDescription>
{user.name} ({user.email}). {content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={performAction.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={performAction.isPending}
onClick={() => performAction.mutate()}
variant={content.variant}
>
{performAction.isPending ? "Working…" : content.confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,180 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@basango/ui/components/dialog";
import { Field, FieldError, FieldGroup, FieldLabel } from "@basango/ui/components/field";
import { Input } from "@basango/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@basango/ui/components/select";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { useZodForm } from "#dashboard/app/hooks/use-zod-form";
import { createManagedUser, managedUsersQueryKey } from "../managed-users";
import { type CreateUserFormValues, createUserFormSchema } from "../user-form-schema";
type UserCreateDialogProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function UserCreateDialog({ onOpenChange, open }: UserCreateDialogProps) {
const queryClient = useQueryClient();
const form = useZodForm(createUserFormSchema, {
defaultValues: {
confirmPassword: "",
email: "",
name: "",
password: "",
role: "user",
},
});
const createUser = useMutation({
mutationFn: createManagedUser,
onError(error) {
toast.error(error.message);
},
onSuccess() {
toast.success("User created.");
void queryClient.invalidateQueries({ queryKey: managedUsersQueryKey() });
form.reset();
onOpenChange(false);
},
});
function handleSubmit(values: CreateUserFormValues) {
createUser.mutate({
email: values.email,
name: values.name,
password: values.password,
role: values.role,
});
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create user</DialogTitle>
<DialogDescription>Add an account and choose its initial access level.</DialogDescription>
</DialogHeader>
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
autoComplete="name"
disabled={createUser.isPending}
id={field.name}
placeholder="Patrice Lumumba"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
autoComplete="email"
disabled={createUser.isPending}
id={field.name}
placeholder="patrice@example.com"
type="email"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="role"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Role</FieldLabel>
<Select
disabled={createUser.isPending}
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger id={field.name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Administrator</SelectItem>
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Temporary password</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
autoComplete="new-password"
disabled={createUser.isPending}
id={field.name}
type="password"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="confirmPassword"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Confirm password</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
autoComplete="new-password"
disabled={createUser.isPending}
id={field.name}
type="password"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
<SubmitButton className="w-full" isSubmitting={createUser.isPending} type="submit">
Create user
</SubmitButton>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,128 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@basango/ui/components/dialog";
import { Field, FieldError, FieldGroup, FieldLabel } from "@basango/ui/components/field";
import { Input } from "@basango/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@basango/ui/components/select";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { useZodForm } from "#dashboard/app/hooks/use-zod-form";
import { type ManagedUser, managedUsersQueryKey, updateManagedUser } from "../managed-users";
import { type EditUserFormValues, editUserFormSchema } from "../user-form-schema";
type UserEditDialogProps = {
isCurrentUser: boolean;
onOpenChange: (open: boolean) => void;
open: boolean;
user: ManagedUser;
};
export function UserEditDialog({ isCurrentUser, onOpenChange, open, user }: UserEditDialogProps) {
const queryClient = useQueryClient();
const form = useZodForm(editUserFormSchema, {
defaultValues: {
email: user.email,
name: user.name,
role: user.role?.split(",").includes("admin") ? "admin" : "user",
},
});
const updateUser = useMutation({
mutationFn: updateManagedUser,
onError(error) {
toast.error(error.message);
},
onSuccess() {
toast.success("User updated.");
void queryClient.invalidateQueries({ queryKey: managedUsersQueryKey() });
onOpenChange(false);
},
});
function handleSubmit(values: EditUserFormValues) {
updateUser.mutate({ ...values, userId: user.id });
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit user</DialogTitle>
<DialogDescription>Update {user.name}&apos;s profile and access level.</DialogDescription>
</DialogHeader>
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input {...field} disabled={updateUser.isPending} id={field.name} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input {...field} disabled={updateUser.isPending} id={field.name} type="email" />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="role"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Role</FieldLabel>
<Select
disabled={isCurrentUser || updateUser.isPending}
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger id={field.name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Administrator</SelectItem>
</SelectContent>
</Select>
{isCurrentUser ? (
<p className="text-xs text-muted-foreground">
You cannot change your own administrator role.
</p>
) : null}
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
<SubmitButton className="w-full" isSubmitting={updateUser.isPending} type="submit">
Save changes
</SubmitButton>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,102 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@basango/ui/components/dialog";
import { Field, FieldError, FieldGroup, FieldLabel } from "@basango/ui/components/field";
import { Input } from "@basango/ui/components/input";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { useMutation } from "@tanstack/react-query";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { useZodForm } from "#dashboard/app/hooks/use-zod-form";
import { type ManagedUser, setManagedUserPassword } from "../managed-users";
import { type SetUserPasswordFormValues, setUserPasswordFormSchema } from "../user-form-schema";
type UserPasswordDialogProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
user: ManagedUser;
};
export function UserPasswordDialog({ onOpenChange, open, user }: UserPasswordDialogProps) {
const form = useZodForm(setUserPasswordFormSchema, {
defaultValues: {
confirmPassword: "",
password: "",
},
});
const setPassword = useMutation({
mutationFn: ({ password }: { password: string }) => setManagedUserPassword(user.id, password),
onError(error) {
toast.error(error.message);
},
onSuccess() {
toast.success("Password updated.");
form.reset();
onOpenChange(false);
},
});
function handleSubmit(values: SetUserPasswordFormValues) {
setPassword.mutate({ password: values.password });
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set password</DialogTitle>
<DialogDescription>Choose a new password for {user.name}.</DialogDescription>
</DialogHeader>
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>New password</FieldLabel>
<Input
{...field}
autoComplete="new-password"
disabled={setPassword.isPending}
id={field.name}
type="password"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
control={form.control}
name="confirmPassword"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Confirm password</FieldLabel>
<Input
{...field}
autoComplete="new-password"
disabled={setPassword.isPending}
id={field.name}
type="password"
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
<SubmitButton className="w-full" isSubmitting={setPassword.isPending} type="submit">
Update password
</SubmitButton>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,129 @@
import { queryOptions } from "@tanstack/react-query";
import { authClient } from "#dashboard/app/auth/auth-client";
export type ManagedUser = NonNullable<
Awaited<ReturnType<typeof authClient.admin.listUsers>>["data"]
>["users"][number];
export type ManagedUsersQuery = {
limit: number;
page: number;
search?: string;
sortBy?: string;
sortDirection?: "asc" | "desc";
};
export function managedUsersQueryKey() {
return ["better-auth", "admin", "users"] as const;
}
export function managedUsersQueryOptions({
limit,
page,
search,
sortBy,
sortDirection,
}: ManagedUsersQuery) {
const normalizedSearch = search?.trim() || undefined;
return queryOptions({
queryFn: async () => {
const result = await authClient.admin.listUsers({
query: {
limit,
offset: (page - 1) * limit,
searchField: "email",
searchOperator: "contains",
searchValue: normalizedSearch,
sortBy: sortBy ?? "createdAt",
sortDirection: sortDirection ?? "desc",
},
});
return requireAuthData(result, "Unable to load users.");
},
queryKey: [
...managedUsersQueryKey(),
{ limit, page, search: normalizedSearch, sortBy, sortDirection },
],
});
}
export async function createManagedUser(input: {
email: string;
name: string;
password: string;
role: "admin" | "user";
}) {
const result = await authClient.admin.createUser(input);
return requireAuthData(result, "Unable to create user.");
}
export async function updateManagedUser(input: {
email: string;
name: string;
role: "admin" | "user";
userId: string;
}) {
const result = await authClient.admin.updateUser({
data: {
email: input.email,
name: input.name,
role: input.role,
},
userId: input.userId,
});
return requireAuthData(result, "Unable to update user.");
}
export async function banManagedUser(input: {
banExpiresIn?: number;
banReason: string;
userId: string;
}) {
const result = await authClient.admin.banUser(input);
return requireAuthData(result, "Unable to ban user.");
}
export async function unbanManagedUser(userId: string) {
const result = await authClient.admin.unbanUser({ userId });
return requireAuthData(result, "Unable to unban user.");
}
export async function revokeManagedUserSessions(userId: string) {
const result = await authClient.admin.revokeUserSessions({ userId });
return requireAuthData(result, "Unable to revoke user sessions.");
}
export async function setManagedUserPassword(userId: string, newPassword: string) {
const result = await authClient.admin.setUserPassword({ newPassword, userId });
return requireAuthData(result, "Unable to update user password.");
}
export async function deleteManagedUser(userId: string) {
const result = await authClient.admin.removeUser({ userId });
return requireAuthData(result, "Unable to delete user.");
}
function requireAuthData<T>(
result: { data: T | null; error: { message?: string } | null },
fallbackMessage: string,
): T {
if (result.error) {
throw new Error(result.error.message || fallbackMessage);
}
if (!result.data) {
throw new Error(fallbackMessage);
}
return result.data;
}
@@ -0,0 +1,114 @@
"use client";
import { Button } from "@basango/ui/components/button";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { PageLayout } from "#dashboard/app/components/page-layout";
import { useUser } from "#dashboard/app/hooks/use-user";
import type { UserRowAction } from "../components/user-row-actions";
import { UsersTable } from "../components/users-table";
import { UserBanDialog } from "../dialogs/user-ban-dialog";
import {
type UserConfirmAction,
UserConfirmActionDialog,
} from "../dialogs/user-confirm-action-dialog";
import { UserCreateDialog } from "../dialogs/user-create-dialog";
import { UserEditDialog } from "../dialogs/user-edit-dialog";
import { UserPasswordDialog } from "../dialogs/user-password-dialog";
import type { ManagedUser } from "../managed-users";
type SelectedUserAction = {
action: UserRowAction;
user: ManagedUser;
};
export function UsersPage() {
const currentUser = useUser();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [selected, setSelected] = useState<SelectedUserAction>();
function handleAction(action: UserRowAction, user: ManagedUser) {
setSelected({ action, user });
}
function closeSelectedAction() {
setSelected(undefined);
}
return (
<PageLayout
actions={
<Button onClick={() => setIsCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Add user
</Button>
}
description="Create accounts, control access, and respond to account security issues."
title="Users"
>
<UsersTable currentUserId={currentUser.user?.id} onAction={handleAction} />
<UserCreateDialog onOpenChange={setIsCreateOpen} open={isCreateOpen} />
{selected?.action === "edit" ? (
<UserEditDialog
isCurrentUser={selected.user.id === currentUser.user?.id}
key={selected.user.id}
onOpenChange={(open) => {
if (!open) {
closeSelectedAction();
}
}}
open
user={selected.user}
/>
) : null}
{selected?.action === "ban" ? (
<UserBanDialog
key={selected.user.id}
onOpenChange={(open) => {
if (!open) {
closeSelectedAction();
}
}}
open
user={selected.user}
/>
) : null}
{selected?.action === "password" ? (
<UserPasswordDialog
key={selected.user.id}
onOpenChange={(open) => {
if (!open) {
closeSelectedAction();
}
}}
open
user={selected.user}
/>
) : null}
{selected && isConfirmAction(selected.action) ? (
<UserConfirmActionDialog
action={selected.action}
key={`${selected.action}:${selected.user.id}`}
onOpenChange={(open) => {
if (!open) {
closeSelectedAction();
}
}}
open
user={selected.user}
/>
) : null}
</PageLayout>
);
}
function isConfirmAction(action: UserRowAction): action is UserConfirmAction {
return action === "delete" || action === "revoke" || action === "unban";
}
@@ -0,0 +1,59 @@
import { passwordSchema } from "@basango/domain/models";
import { z } from "zod";
const roleSchema = z.enum(["user", "admin"]);
const emailSchema = z.string().trim().toLowerCase().pipe(z.email("Enter a valid email address"));
const nameSchema = z.string().trim().min(2, "Name must be at least 2 characters").max(255);
export const createUserFormSchema = z
.object({
confirmPassword: passwordSchema,
email: emailSchema,
name: nameSchema,
password: passwordSchema,
role: roleSchema,
})
.refine((values) => values.password === values.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});
export type CreateUserFormValues = z.infer<typeof createUserFormSchema>;
export const editUserFormSchema = z.object({
email: emailSchema,
name: nameSchema,
role: roleSchema,
});
export type EditUserFormValues = z.infer<typeof editUserFormSchema>;
export const banUserFormSchema = z.object({
banReason: z.string().trim().min(3, "Give a short reason for this ban").max(500),
duration: z.enum(["day", "week", "month", "permanent"]),
});
export type BanUserFormValues = z.infer<typeof banUserFormSchema>;
export const setUserPasswordFormSchema = z
.object({
confirmPassword: passwordSchema,
password: passwordSchema,
})
.refine((values) => values.password === values.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});
export type SetUserPasswordFormValues = z.infer<typeof setUserPasswordFormSchema>;
export function getBanDurationSeconds(duration: BanUserFormValues["duration"]): number | undefined {
const durationInSeconds = {
day: 60 * 60 * 24,
month: 60 * 60 * 24 * 30,
permanent: undefined,
week: 60 * 60 * 24 * 7,
} as const;
return durationInSeconds[duration];
}
@@ -7,7 +7,6 @@ import {
CardTitle,
} from "@basango/ui/components/card";
import { Skeleton } from "@basango/ui/components/skeleton";
import type { LucideIcon } from "lucide-react";
import { Server, Wifi, WifiOff } from "lucide-react";
import type { ReactNode } from "react";
@@ -36,43 +35,6 @@ export function DashboardPanel({
);
}
export function MetricCard({
detail,
icon: Icon,
label,
suffix,
tone,
value,
}: {
detail: string;
icon: LucideIcon;
label: string;
suffix?: string;
tone: "danger" | "healthy" | "neutral" | "warning";
value: number;
}) {
const toneClass = {
danger: "text-destructive",
healthy: "text-emerald-600 dark:text-emerald-400",
neutral: "text-foreground",
warning: "text-amber-600 dark:text-amber-400",
}[tone];
return (
<Card className="flex flex-col gap-2 border-border bg-card p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-4xl font-semibold tabular-nums">
{value.toLocaleString()}
{suffix}
</p>
<div className="flex items-center gap-2 text-sm font-medium">
<Icon className={`size-4 ${toneClass}`} />
<span className="text-muted-foreground">{detail}</span>
</div>
</Card>
);
}
type LiveBadgeProps = {
status: "connecting" | "disconnected" | "live";
};
@@ -2,12 +2,13 @@
import { Activity, ArrowDownToLine, CircleCheck, Radio } from "lucide-react";
import { MetricCard } from "#dashboard/app/components/metric-card";
import { IngestionRunsTable } from "#dashboard/features/ingestion/runs/components/ingestion-runs-table";
import { createDashboardModel } from "../ingestion-metrics";
import type { IngestionDashboardData } from "../types";
import { PipelineStagesPanel, RunDurationPanel, ThroughputPanel } from "./chart-panels";
import { IngestionOperationsSkeleton, MetricCard } from "./dashboard-primitives";
import { IngestionOperationsSkeleton } from "./dashboard-primitives";
import { AgentHealthPanel } from "./status-panels";
export function IngestionOperations({
+42
View File
@@ -10,12 +10,14 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as CategoriesRouteImport } from './routes/categories'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as IngestionRouteImport } from './routes/ingestion'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as TimelineRouteImport } from './routes/timeline'
import { Route as UsersRouteImport } from './routes/users'
import { Route as AgentsAgentIdRouteImport } from './routes/agents/$agentId'
import { Route as ArticlesIndexRouteImport } from './routes/articles/index'
import { Route as ArticlesIdRouteImport } from './routes/articles/$id'
@@ -28,6 +30,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const CategoriesRoute = CategoriesRouteImport.update({
id: '/categories',
path: '/categories',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
@@ -58,6 +65,11 @@ const TimelineRoute = TimelineRouteImport.update({
path: '/timeline',
getParentRoute: () => rootRouteImport,
} as any)
const UsersRoute = UsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsAgentIdRoute = AgentsAgentIdRouteImport.update({
id: '/agents/$agentId',
path: '/agents/$agentId',
@@ -91,12 +103,14 @@ const SourcesIdRoute = SourcesIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/categories': typeof CategoriesRoute
'/dashboard': typeof DashboardRoute
'/forgot-password': typeof ForgotPasswordRoute
'/ingestion': typeof IngestionRoute
'/login': typeof LoginRoute
'/reset-password': typeof ResetPasswordRoute
'/timeline': typeof TimelineRoute
'/users': typeof UsersRoute
'/agents/$agentId': typeof AgentsAgentIdRoute
'/articles/$id': typeof ArticlesIdRoute
'/runs/$runId': typeof RunsRunIdRoute
@@ -106,12 +120,14 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/categories': typeof CategoriesRoute
'/dashboard': typeof DashboardRoute
'/forgot-password': typeof ForgotPasswordRoute
'/ingestion': typeof IngestionRoute
'/login': typeof LoginRoute
'/reset-password': typeof ResetPasswordRoute
'/timeline': typeof TimelineRoute
'/users': typeof UsersRoute
'/agents/$agentId': typeof AgentsAgentIdRoute
'/articles/$id': typeof ArticlesIdRoute
'/runs/$runId': typeof RunsRunIdRoute
@@ -122,12 +138,14 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/categories': typeof CategoriesRoute
'/dashboard': typeof DashboardRoute
'/forgot-password': typeof ForgotPasswordRoute
'/ingestion': typeof IngestionRoute
'/login': typeof LoginRoute
'/reset-password': typeof ResetPasswordRoute
'/timeline': typeof TimelineRoute
'/users': typeof UsersRoute
'/agents/$agentId': typeof AgentsAgentIdRoute
'/articles/$id': typeof ArticlesIdRoute
'/runs/$runId': typeof RunsRunIdRoute
@@ -139,12 +157,14 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/categories'
| '/dashboard'
| '/forgot-password'
| '/ingestion'
| '/login'
| '/reset-password'
| '/timeline'
| '/users'
| '/agents/$agentId'
| '/articles/$id'
| '/runs/$runId'
@@ -154,12 +174,14 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/categories'
| '/dashboard'
| '/forgot-password'
| '/ingestion'
| '/login'
| '/reset-password'
| '/timeline'
| '/users'
| '/agents/$agentId'
| '/articles/$id'
| '/runs/$runId'
@@ -169,12 +191,14 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/categories'
| '/dashboard'
| '/forgot-password'
| '/ingestion'
| '/login'
| '/reset-password'
| '/timeline'
| '/users'
| '/agents/$agentId'
| '/articles/$id'
| '/runs/$runId'
@@ -185,12 +209,14 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
CategoriesRoute: typeof CategoriesRoute
DashboardRoute: typeof DashboardRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
IngestionRoute: typeof IngestionRoute
LoginRoute: typeof LoginRoute
ResetPasswordRoute: typeof ResetPasswordRoute
TimelineRoute: typeof TimelineRoute
UsersRoute: typeof UsersRoute
AgentsAgentIdRoute: typeof AgentsAgentIdRoute
ArticlesIdRoute: typeof ArticlesIdRoute
RunsRunIdRoute: typeof RunsRunIdRoute
@@ -208,6 +234,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/categories': {
id: '/categories'
path: '/categories'
fullPath: '/categories'
preLoaderRoute: typeof CategoriesRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
@@ -250,6 +283,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TimelineRouteImport
parentRoute: typeof rootRouteImport
}
'/users': {
id: '/users'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof UsersRouteImport
parentRoute: typeof rootRouteImport
}
'/agents/$agentId': {
id: '/agents/$agentId'
path: '/agents/$agentId'
@@ -297,12 +337,14 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
CategoriesRoute: CategoriesRoute,
DashboardRoute: DashboardRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
IngestionRoute: IngestionRoute,
LoginRoute: LoginRoute,
ResetPasswordRoute: ResetPasswordRoute,
TimelineRoute: TimelineRoute,
UsersRoute: UsersRoute,
AgentsAgentIdRoute: AgentsAgentIdRoute,
ArticlesIdRoute: ArticlesIdRoute,
RunsRunIdRoute: RunsRunIdRoute,
+16
View File
@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { requireAdminSession } from "#dashboard/app/auth/route-guards";
import { CategoriesPage } from "#dashboard/features/content/categories/pages/categories-page";
export const Route = createFileRoute("/categories")({
beforeLoad: ({ location }) => requireAdminSession(location.href),
loader: ({ context }) => {
void context.queryClient.prefetchQuery(context.trpc.categories.list.queryOptions());
void context.queryClient.prefetchQuery(context.trpc.categories.stats.queryOptions());
},
head: () => ({
meta: [{ title: "Categories | Basango Dashboard" }],
}),
component: CategoriesPage,
});
+12
View File
@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { requireAdminSession } from "#dashboard/app/auth/route-guards";
import { UsersPage } from "#dashboard/features/identity/users/pages/users-page";
export const Route = createFileRoute("/users")({
beforeLoad: ({ location }) => requireAdminSession(location.href),
head: () => ({
meta: [{ title: "Users | Basango Dashboard" }],
}),
component: UsersPage,
});
-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
-1
View File
@@ -1 +0,0 @@
{ "recommendations": ["expo.vscode-expo-tools"] }
-7
View File
@@ -1,7 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+4 -4
View File
@@ -23,10 +23,10 @@ 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/app` contains Expo Router screen re-exports and framework-owned `_layout.tsx` navigators.
- `src/application` owns authentication, runtime configuration, providers, app-level screens, and
the typed tRPC client.
- `src/features` groups reader capabilities and their native screens.
- `src/ui` contains the application-local, shadcn-inspired React Native primitives.
- `src/global.css` owns the Uniwind theme tokens and light/dark palettes.
@@ -1,152 +1 @@
import { MailIcon, ShieldCheckIcon, SunMoonIcon, UserRoundIcon } from "lucide-react-native";
import { ActionSheetIOS, Alert, ScrollView } from "react-native";
import { XStack, YStack } from "tamagui";
import {
type AppearancePreference,
appearanceLabels,
useAppearance,
} from "#mobile/application/appearance";
import { authClient } from "#mobile/application/auth/auth-client";
import { GroupedIcon, GroupedRow, GroupedSection } from "#mobile/ui/components/grouped-list";
import { SourceAvatar } from "#mobile/ui/components/source-avatar";
import { LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { screenBottomPadding, screenGutter, sectionGap } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function AccountRoute() {
const { preference, resolvedScheme, setPreference } = useAppearance();
const colors = useAppColors();
const session = authClient.useSession();
const user = session.data?.user;
async function handleSignOut() {
const result = await authClient.signOut();
if (result.error) {
Alert.alert("Déconnexion impossible", result.error.message ?? "Réessayez dans un instant.");
}
}
function confirmSignOut() {
Alert.alert("Se déconnecter ?", "Vous devrez vous reconnecter pour accéder à votre compte.", [
{ style: "cancel", text: "Annuler" },
{ onPress: () => void handleSignOut(), style: "destructive", text: "Se déconnecter" },
]);
}
function chooseAppearance() {
const preferences: AppearancePreference[] = ["system", "light", "dark"];
const options = preferences.map((option) =>
option === preference ? `${appearanceLabels[option]}` : appearanceLabels[option],
);
ActionSheetIOS.showActionSheetWithOptions(
{
cancelButtonIndex: options.length,
options: [...options, "Annuler"],
title: "Apparence",
userInterfaceStyle: resolvedScheme,
},
(selectedIndex) => {
const selectedPreference = preferences[selectedIndex];
if (selectedPreference) {
setPreference(selectedPreference);
}
},
);
}
return (
<ScrollView
contentContainerStyle={{
gap: sectionGap,
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
>
{!user ? (
<LoadingState label="Chargement du profil…" />
) : (
<>
<GroupedSection>
<XStack alignItems="center" gap="$4" minHeight={88} padding="$4">
<SourceAvatar name={user.name} size="medium" />
<YStack flex={1} gap="$0.5">
<Text fontSize="$6" fontWeight="600" numberOfLines={1}>
{user.name}
</Text>
<Text numberOfLines={1} variant="caption">
{user.email}
</Text>
</YStack>
</XStack>
</GroupedSection>
<GroupedSection title="Informations personnelles">
<GroupedRow
icon={
<GroupedIcon>
<UserRoundIcon color="white" size={18} strokeWidth={1.9} />
</GroupedIcon>
}
label="Nom"
showSeparator
value={user.name}
/>
<GroupedRow
icon={
<GroupedIcon>
<MailIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Adresse e-mail"
showSeparator
value={user.email}
/>
<GroupedRow
icon={
<GroupedIcon>
<ShieldCheckIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Compte"
value={user.emailVerified ? "E-mail vérifié" : "E-mail non vérifié"}
/>
</GroupedSection>
<GroupedSection title="Apparence">
<GroupedRow
accessibilityHint="Choisit le thème système, clair ou sombre"
icon={
<GroupedIcon>
<SunMoonIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Thème"
onPress={chooseAppearance}
value={appearanceLabels[preference]}
/>
</GroupedSection>
<GroupedSection>
<GroupedRow
accessibilityHint="Ferme la session sur cet appareil"
destructive
label="Se déconnecter"
onPress={confirmSignOut}
/>
</GroupedSection>
<Text textAlign="center" variant="caption">
Basango · Lactualité qui vous rapproche
</Text>
</>
)}
</ScrollView>
);
}
export { AccountScreen as default } from "#mobile/features/identity/account/screens/account-screen";
@@ -1,139 +1 @@
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
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 { formatPublicationDate } from "#mobile/features/content/shared/format-publication-date";
import { toPlainText } from "#mobile/features/content/shared/to-plain-text";
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";
export default function ArticleDetailsRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const trpc = useTRPC();
const article = useQuery(trpc.feed.articles.get.queryOptions({ id }));
async function handleShare() {
if (!article.data) {
return;
}
await Share.share({
message: `${article.data.title}\n${article.data.link}`,
title: article.data.title,
url: article.data.link,
});
}
if (article.isPending) {
return (
<Screen hasNativeHeader>
<LoadingState label="Chargement de larticle…" />
</Screen>
);
}
if (article.isError) {
return (
<Screen hasNativeHeader>
<ErrorState onRetry={() => void article.refetch()} />
</Screen>
);
}
return (
<Screen hasNativeHeader>
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button
accessibilityLabel="Ajouter aux signets"
icon="bookmark"
onPress={() =>
router.push({
params: { articleId: article.data.id },
pathname: "/(app)/(tabs)/articles/bookmark-picker",
})
}
/>
<Stack.Toolbar.Button
accessibilityLabel="Partager larticle"
icon="square.and.arrow.up"
onPress={handleShare}
/>
<Stack.Toolbar.Menu accessibilityLabel="Actions de larticle" icon="ellipsis">
<Stack.Toolbar.MenuAction
icon="bubble.left"
onPress={() =>
router.push({
params: { articleId: article.data.id },
pathname: "/(app)/(tabs)/articles/comments",
})
}
>
Commentaires
</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction
icon="safari"
onPress={() => void Linking.openURL(article.data.link)}
>
Ouvrir sur le site
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
</Stack.Toolbar>
<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>
</YStack>
</ScrollView>
</Screen>
);
}
export { ArticleDetailsScreen as default } from "#mobile/features/content/articles/screens/article-details-screen";
@@ -34,7 +34,7 @@ export default function ArticlesLayout() {
options={{
...fadeHeaderOptions,
presentation: "pageSheet",
title: "Ajouter à un signet",
title: "Signets",
}}
/>
<Stack.Screen
@@ -1,71 +1 @@
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 }}
/>
);
}
export { AllArticlesScreen as default } from "#mobile/features/content/articles/screens/all-articles-screen";
@@ -1,26 +1 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { BookmarkPicker } from "#mobile/features/content/bookmarks/components/bookmark-picker";
import { ErrorState } from "#mobile/ui/components/status-state";
export default function ArticleBookmarkPickerRoute() {
const { articleId } = useLocalSearchParams<{ articleId?: string }>();
const router = useRouter();
return (
<>
<Stack.Toolbar placement="left">
<Stack.Toolbar.Button
accessibilityLabel="Fermer"
icon="xmark"
onPress={() => router.back()}
/>
</Stack.Toolbar>
{articleId ? (
<BookmarkPicker articleId={articleId} onComplete={() => router.back()} />
) : (
<ErrorState description="Cet article est introuvable." />
)}
</>
);
}
export { ArticleBookmarkPickerScreen as default } from "#mobile/features/content/bookmarks/screens/article-bookmark-picker-screen";
@@ -1,26 +1 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { ArticleComments } from "#mobile/features/content/comments/components/article-comments";
import { ErrorState } from "#mobile/ui/components/status-state";
export default function ArticleCommentsRoute() {
const { articleId } = useLocalSearchParams<{ articleId?: string }>();
const router = useRouter();
return (
<>
<Stack.Toolbar placement="left">
<Stack.Toolbar.Button
accessibilityLabel="Fermer"
icon="xmark"
onPress={() => router.back()}
/>
</Stack.Toolbar>
{articleId ? (
<ArticleComments articleId={articleId} enabled />
) : (
<ErrorState description="Cet article est introuvable." />
)}
</>
);
}
export { ArticleCommentsScreen as default } from "#mobile/features/content/comments/screens/article-comments-screen";
@@ -1,106 +1 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
import { RefreshControl, ScrollView } from "react-native";
import { YStack } from "tamagui";
import { ArticleCard } from "#mobile/features/content/articles/components/article-card";
import { ArticleListFooter } from "#mobile/features/content/articles/components/article-list-footer";
import { FeaturedArticleCard } from "#mobile/features/content/articles/components/featured-article-card";
import { useInfiniteArticles } from "#mobile/features/content/articles/hooks/use-infinite-articles";
import { SectionHeader } from "#mobile/ui/components/section-header";
import { ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { screenBottomPadding, screenGutter, sectionGap } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
export default function ArticlesHomeRoute() {
const colors = useAppColors();
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const articleFeed = useInfiniteArticles();
async function handleRefresh() {
setIsRefreshing(true);
try {
await articleFeed.refetch();
} finally {
setIsRefreshing(false);
}
}
function handleFeedScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
if (distanceFromBottom < 480) {
articleFeed.loadNextPage();
}
}
const articleItems = articleFeed.articles;
const featuredArticles = articleItems.slice(0, 5);
const latestArticles = articleItems.slice(5);
return (
<ScrollView
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
onScroll={handleFeedScroll}
refreshControl={
<RefreshControl
colors={[colors.primary]}
onRefresh={() => void handleRefresh()}
refreshing={isRefreshing}
tintColor={colors.primary}
/>
}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.background, flex: 1 }}
>
{articleFeed.isPending ? (
<LoadingState label="Chargement de lactualité…" />
) : articleFeed.isError && articleItems.length === 0 ? (
<ErrorState onRetry={() => void handleRefresh()} />
) : (
<YStack gap="$5">
<YStack gap="$2">
<SectionHeader
onAction={() => router.push("/(app)/(tabs)/articles/all")}
title="À la une"
/>
<ScrollView
contentContainerStyle={{ gap: sectionGap }}
horizontal
showsHorizontalScrollIndicator={false}
>
{featuredArticles.map((article) => (
<FeaturedArticleCard article={article} key={article.id} />
))}
</ScrollView>
</YStack>
<YStack gap="$2">
<SectionHeader
onAction={() => router.push("/(app)/(tabs)/articles/all")}
title="Dernières actualités"
/>
{latestArticles.map((article) => (
<ArticleCard article={article} key={article.id} />
))}
<ArticleListFooter
hasError={articleFeed.isFetchNextPageError}
isLoading={articleFeed.isFetchingNextPage}
onRetry={articleFeed.loadNextPage}
/>
</YStack>
</YStack>
)}
</ScrollView>
);
}
export { ArticlesHomeScreen as default } from "#mobile/features/content/articles/screens/articles-home-screen";
@@ -1,162 +1 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { Trash2Icon } from "lucide-react-native";
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 { useInfiniteBookmarkArticles } from "#mobile/features/content/bookmarks/hooks/use-infinite-bookmark-articles";
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 bookmarks = useQuery(trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }));
const articles = useInfiniteBookmarkArticles(id);
const deleteBookmark = useMutation(
trpc.feed.bookmarks.delete.mutationOptions({
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
router.back();
},
}),
);
const removeArticle = useMutation(
trpc.feed.bookmarks.removeArticle.mutationOptions({
onSuccess() {
void articles.refetch();
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
},
}),
);
const bookmark = bookmarks.data?.items.find((item) => item.id === id);
function handleDeleteBookmark() {
Alert.alert("Supprimer ce signet ?", "La collection sera supprimée, pas ses articles.", [
{ style: "cancel", text: "Annuler" },
{
onPress: () => deleteBookmark.mutate({ id }),
style: "destructive",
text: "Supprimer",
},
]);
}
function handleRemoveArticle(articleId: string) {
Alert.alert("Retirer larticle ?", "Il ne figurera plus dans cette collection.", [
{ style: "cancel", text: "Annuler" },
{
onPress: () => removeArticle.mutate({ articleId, bookmarkId: id }),
style: "destructive",
text: "Retirer",
},
]);
}
if (bookmarks.isPending || articles.isPending) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<LoadingState label="Chargement du signet…" />
</Screen>
);
}
if (bookmarks.isError || (articles.isError && articles.articles.length === 0) || !bookmark) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<ErrorState onRetry={() => void Promise.all([bookmarks.refetch(), articles.refetch()])} />
</Screen>
);
}
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<Stack.Title style={{ color: colors.foreground }}>{bookmark.name}</Stack.Title>
<Stack.Toolbar placement="right">
<Stack.Toolbar.Menu accessibilityLabel="Actions du signet" icon="ellipsis">
<Stack.Toolbar.MenuAction
icon="pencil"
onPress={() =>
router.push({
params: { bookmarkId: bookmark.id },
pathname: "/(app)/(tabs)/bookmarks/form",
})
}
>
Modifier
</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction
destructive
disabled={deleteBookmark.isPending}
icon="trash"
onPress={handleDeleteBookmark}
>
Supprimer
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
</Stack.Toolbar>
<FlatList
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
data={articles.articles}
ItemSeparatorComponent={() => <YStack height="$2" />}
keyExtractor={(article) => article.id}
ListEmptyComponent={
<EmptyState
description="Ouvrez un article et touchez licône de signet pour lajouter ici."
title="Collection vide"
/>
}
ListFooterComponent={
<ArticleListFooter
hasError={articles.isFetchNextPageError}
isLoading={articles.isFetchingNextPage}
onRetry={articles.loadNextPage}
/>
}
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 }}
/>
</Screen>
);
}
export { BookmarkDetailsScreen as default } from "#mobile/features/content/bookmarks/screens/bookmark-details-screen";
@@ -1,31 +1 @@
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useTRPC } from "#mobile/application/trpc/client";
import { BookmarkForm } from "#mobile/features/content/bookmarks/components/bookmark-form";
import { ErrorState, LoadingState } from "#mobile/ui/components/status-state";
export default function BookmarkFormRoute() {
const { bookmarkId } = useLocalSearchParams<{ bookmarkId?: string }>();
const trpc = useTRPC();
const bookmarks = useQuery({
...trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }),
enabled: Boolean(bookmarkId),
});
if (bookmarkId && bookmarks.isPending) {
return <LoadingState label="Chargement du signet…" />;
}
if (bookmarkId && bookmarks.isError) {
return <ErrorState onRetry={() => void bookmarks.refetch()} />;
}
const bookmark = bookmarks.data?.items.find((item) => item.id === bookmarkId);
if (bookmarkId && !bookmark) {
return <ErrorState description="Ce signet est introuvable." />;
}
return <BookmarkForm bookmark={bookmark} />;
}
export { BookmarkFormScreen as default } from "#mobile/features/content/bookmarks/screens/bookmark-form-screen";
@@ -1,78 +1 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useRouter } 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 { BookmarkCard } from "#mobile/features/content/bookmarks/components/bookmark-card";
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 router = useRouter();
const trpc = useTRPC();
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.Toolbar placement="right">
<Stack.Toolbar.Button
accessibilityLabel="Créer un signet"
icon="plus"
onPress={() => router.push("/(app)/(tabs)/bookmarks/form")}
/>
</Stack.Toolbar>
<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>
</>
);
}
export { BookmarksScreen as default } from "#mobile/features/content/bookmarks/screens/bookmarks-screen";
@@ -1,146 +1 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import { FlatList, Linking, RefreshControl } 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 { useSourceFollowAction } from "#mobile/features/content/sources/hooks/use-source-follow-action";
import type { Source } from "#mobile/features/content/types";
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.Title style={{ color: colors.foreground }}>{displayName}</Stack.Title>
<SourceToolbar source={source.data} />
<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>
</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>
);
}
type SourceToolbarProps = {
source: Source;
};
function SourceToolbar({ source }: SourceToolbarProps) {
const followAction = useSourceFollowAction(source);
return (
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button
accessibilityLabel={source.followed ? "Ne plus suivre" : "Suivre"}
disabled={followAction.isPending}
icon={source.followed ? "checkmark" : "plus"}
onPress={followAction.toggleFollow}
/>
<Stack.Toolbar.Button
accessibilityLabel="Ouvrir le site"
icon="safari"
onPress={() => void Linking.openURL(source.url)}
/>
</Stack.Toolbar>
);
}
export { SourceDetailsScreen as default } from "#mobile/features/content/sources/screens/source-details-screen";
@@ -1,134 +1 @@
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>
</>
);
}
export { SourcesScreen as default } from "#mobile/features/content/sources/screens/sources-screen";
+1 -106
View File
@@ -1,106 +1 @@
import { requestPasswordResetSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type ForgotPasswordForm = z.infer<typeof requestPasswordResetSchema>;
export default function ForgotPasswordRoute() {
const form = useForm<ForgotPasswordForm>({
defaultValues: { email: "" },
mode: "onChange",
resolver: zodResolver(requestPasswordResetSchema),
});
const message = form.formState.errors.root?.message;
async function handleRequest(values: ForgotPasswordForm) {
form.clearErrors("root");
const result = await authClient.requestPasswordReset({
email: values.email,
redirectTo: "basango://reset-password",
});
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(
result.error,
"Impossible denvoyer le lien de réinitialisation.",
),
});
return;
}
form.setError("root", {
message: "Si cette adresse est associée à un compte, un lien vient de vous être envoyé.",
type: "success",
});
}
const isSuccess = form.formState.errors.root?.type === "success";
return (
<KeyboardScreen hasNativeHeader>
<YStack
flex={1}
gap="$4"
justifyContent="space-between"
paddingBottom="$6"
paddingHorizontal="$4"
paddingTop="$5"
>
<YStack flex={1} gap="$4">
<Text>
Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation de mot de
passe.
</Text>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
{message ? (
<Text color={isSuccess ? "$primary" : "$danger"} variant="caption">
{message}
</Text>
) : null}
<Link asChild href="/(auth)/sign-in">
<Text>Vous avez déjà un compte ? Se connecter</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleRequest)}
>
Réinitialiser le mot de passe
</Button>
</YStack>
</KeyboardScreen>
);
}
export { ForgotPasswordScreen as default } from "#mobile/features/identity/auth/screens/forgot-password-screen";
+1 -122
View File
@@ -1,122 +1 @@
import { loginSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { ScrollView, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { PasswordInput } from "#mobile/ui/components/password-input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type LoginForm = z.infer<typeof loginSchema>;
export default function SignInRoute() {
const form = useForm<LoginForm>({
defaultValues: { email: "", password: "" },
mode: "onChange",
resolver: zodResolver(loginSchema),
});
const error = form.formState.errors.root?.message;
async function handleSignIn(values: LoginForm) {
form.clearErrors("root");
const result = await authClient.signIn.email(values);
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(
result.error,
"Vérifiez votre adresse e-mail et votre mot de passe.",
),
});
}
}
return (
<KeyboardScreen hasNativeHeader>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
gap: 16,
justifyContent: "space-between",
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 20,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<YStack flex={1} gap="$4">
<Text>Bienvenue sur Basango, la plateforme dactualités intelligente.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
error={fieldState.error?.message}
label="Mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Votre mot de passe"
value={field.value}
/>
)}
/>
<Link asChild href="/(auth)/forgot-password">
<Text color="$primary">Mot de passe oublié ?</Text>
</Link>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
<Text variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez
avoir lu notre politique de confidentialité.
</Text>
<Link asChild href="/(auth)/sign-up">
<Text>Vous navez pas de compte ? Créer un compte</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSignIn)}
>
Se connecter
</Button>
</ScrollView>
</KeyboardScreen>
);
}
export { SignInScreen as default } from "#mobile/features/identity/auth/screens/sign-in-screen";
+1 -131
View File
@@ -1,131 +1 @@
import { signUpSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { ScrollView, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { Input } from "#mobile/ui/components/input";
import { PasswordInput } from "#mobile/ui/components/password-input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type SignUpForm = z.infer<typeof signUpSchema>;
export default function SignUpRoute() {
const form = useForm<SignUpForm>({
defaultValues: { email: "", name: "", password: "" },
mode: "onChange",
resolver: zodResolver(signUpSchema),
});
const error = form.formState.errors.root?.message;
async function handleSignUp(values: SignUpForm) {
form.clearErrors("root");
const result = await authClient.signUp.email(values);
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(result.error, "Impossible de créer votre compte."),
});
}
}
return (
<KeyboardScreen hasNativeHeader>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
gap: 16,
justifyContent: "space-between",
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 20,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<YStack flex={1} gap="$4">
<Text>Rejoignez la communauté Basango et restez informé des dernières actualités.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Input
autoComplete="name"
error={fieldState.error?.message}
label="Nom complet"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Votre nom"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="8 caractères minimum"
value={field.value}
/>
)}
/>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
<Text variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez
avoir lu notre politique de confidentialité.
</Text>
<Link asChild href="/(auth)/sign-in">
<Text>Vous avez un compte ? Connectez-vous</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSignUp)}
>
Créer un compte
</Button>
</ScrollView>
</KeyboardScreen>
);
}
export { SignUpScreen as default } from "#mobile/features/identity/auth/screens/sign-up-screen";
+1 -40
View File
@@ -1,40 +1 @@
import { useRouter } from "expo-router";
import { YStack } from "tamagui";
import { Button } from "#mobile/ui/components/button";
import { LogoMark } from "#mobile/ui/components/logo-mark";
import { Screen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
export default function WelcomeRoute() {
const router = useRouter();
return (
<Screen alignItems="center" gap="$4" justifyContent="center" paddingHorizontal="$4">
<LogoMark />
<YStack gap="$6" width="100%">
<YStack gap="$3">
<Text textAlign="center" variant="display">
Bienvenue sur Basango
</Text>
<Text lineHeight="$1" marginTop="auto" textAlign="center">
La première plateforme dactualités intelligente qui vous aide à rester informé sur
lactualité congolaise et internationale.
</Text>
</YStack>
<YStack gap="$4">
<Button onPress={() => router.push("/(auth)/sign-in")}>Se connecter</Button>
<Text onPress={() => router.push("/(auth)/sign-up")} textAlign="center">
Ouvrir un compte
</Text>
</YStack>
<Text textAlign="center" variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez avoir
lu notre politique de confidentialité.
</Text>
</YStack>
</Screen>
);
}
export { WelcomeScreen as default } from "#mobile/features/identity/auth/screens/welcome-screen";
+1 -21
View File
@@ -1,21 +1 @@
import { useRouter } from "expo-router";
import { Button } from "#mobile/ui/components/button";
import { Screen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
export default function NotFoundRoute() {
const router = useRouter();
return (
<Screen alignItems="center" gap="$4" justifyContent="center" paddingHorizontal="$8">
<Text textAlign="center" variant="heading">
Cette page nexiste pas
</Text>
<Text textAlign="center" variant="caption">
Revenez aux actualités pour continuer.
</Text>
<Button onPress={() => router.replace("/")}>Retour à laccueil</Button>
</Screen>
);
}
export { NotFoundScreen as default } from "#mobile/application/navigation/screens/not-found-screen";
+1 -17
View File
@@ -1,17 +1 @@
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" />
);
}
export { IndexScreen as default } from "#mobile/application/navigation/screens/index-screen";
+1 -112
View File
@@ -1,112 +1 @@
import { resetPasswordSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Controller, useForm } from "react-hook-form";
import { YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { getAuthErrorMessage } from "#mobile/features/identity/auth/auth-error";
import { Button } from "#mobile/ui/components/button";
import { PasswordInput } from "#mobile/ui/components/password-input";
import { KeyboardScreen } from "#mobile/ui/components/screen";
import { Text } from "#mobile/ui/components/text";
type ResetPasswordForm = z.infer<typeof resetPasswordSchema>;
export default function ResetPasswordRoute() {
const params = useLocalSearchParams<{ token?: string | string[] }>();
const router = useRouter();
const token = firstParam(params.token);
const form = useForm<ResetPasswordForm>({
defaultValues: { confirmPassword: "", password: "" },
mode: "onChange",
resolver: zodResolver(resetPasswordSchema),
});
const error = form.formState.errors.root?.message;
async function handleReset(values: ResetPasswordForm) {
if (!token) {
form.setError("root", { message: "Ce lien de réinitialisation est incomplet." });
return;
}
form.clearErrors("root");
const result = await authClient.resetPassword({ newPassword: values.password, token });
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(result.error, "Ce lien est invalide ou a expiré."),
});
return;
}
router.replace("/(auth)/sign-in");
}
return (
<KeyboardScreen hasNativeHeader>
<YStack
flex={1}
gap="$4"
justifyContent="space-between"
paddingBottom="$6"
paddingHorizontal="$4"
paddingTop="$5"
>
<YStack flex={1} gap="$4">
<Text>Choisissez un mot de passe dau moins huit caractères.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Nouveau mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="confirmPassword"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Confirmer le mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
</YStack>
<Button
disabled={!form.formState.isValid || !token}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleReset)}
>
Enregistrer le mot de passe
</Button>
</YStack>
</KeyboardScreen>
);
}
function firstParam(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export { ResetPasswordScreen as default } from "#mobile/features/identity/auth/screens/reset-password-screen";
@@ -0,0 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { type FieldValues, type Resolver, type UseFormProps, useForm } from "react-hook-form";
import type { z } from "zod";
export function useZodForm<TSchema extends z.ZodType<FieldValues, FieldValues>>(
schema: TSchema,
options?: Omit<UseFormProps<z.infer<TSchema>>, "resolver">,
) {
return useForm<z.infer<TSchema>>({
...options,
resolver: zodResolver(schema) as unknown as Resolver<z.infer<TSchema>>,
});
}
@@ -0,0 +1,17 @@
import { Redirect } from "expo-router";
import { authClient } from "#mobile/application/auth/auth-client";
export function IndexScreen() {
const session = authClient.useSession();
if (session.isPending) {
return null;
}
return session.data ? (
<Redirect href="/(app)/(tabs)/articles" />
) : (
<Redirect href="/(auth)/welcome" />
);
}
@@ -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 function NotFoundScreen() {
const router = useRouter();
return (
<Screen alignItems="center" gap="$4" justifyContent="center" paddingHorizontal="$8">
<Text textAlign="center" variant="heading">
Cette page nexiste pas
</Text>
<Text textAlign="center" variant="caption">
Revenez aux actualités pour continuer.
</Text>
<Button onPress={() => router.replace("/")}>Retour à laccueil</Button>
</Screen>
);
}
@@ -0,0 +1,75 @@
import { useQuery } from "@tanstack/react-query";
import { ScrollView } from "react-native";
import { Spinner } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { Button } from "#mobile/ui/components/button";
type ArticleCategoryFilterProps = {
onChange: (categoryId: string | undefined) => void;
selectedCategoryId?: string;
};
export function ArticleCategoryFilter({
onChange,
selectedCategoryId,
}: ArticleCategoryFilterProps) {
const trpc = useTRPC();
const categories = useQuery(trpc.feed.categories.list.queryOptions());
if (categories.isPending) {
return <Spinner alignSelf="flex-start" color="$primary" marginVertical="$3" size="small" />;
}
if (categories.isError) {
return (
<Button alignSelf="flex-start" onPress={() => void categories.refetch()} variant="ghost">
Réessayer les catégories
</Button>
);
}
return (
<ScrollView
contentContainerStyle={{ gap: 8 }}
horizontal
showsHorizontalScrollIndicator={false}
>
<CategoryButton
isSelected={selectedCategoryId === undefined}
label="Tout"
onPress={() => onChange(undefined)}
/>
{categories.data.map((category) => (
<CategoryButton
isSelected={selectedCategoryId === category.id}
key={category.id}
label={category.name}
onPress={() => onChange(category.id)}
/>
))}
</ScrollView>
);
}
type CategoryButtonProps = {
isSelected: boolean;
label: string;
onPress: () => void;
};
function CategoryButton({ isSelected, label, onPress }: CategoryButtonProps) {
return (
<Button
accessibilityLabel={`Filtrer par ${label}`}
accessibilityState={{ selected: isSelected }}
borderRadius="$10"
onPress={onPress}
paddingHorizontal="$4"
size="$3"
variant={isSelected ? "primary" : "secondary"}
>
{label}
</Button>
);
}
@@ -0,0 +1,102 @@
import { Stack } from "expo-router";
import { useState } from "react";
import { FlatList, RefreshControl } from "react-native";
import { YStack } from "tamagui";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
import { screenBottomPadding, screenGutter } from "#mobile/ui/layout";
import { useAppColors } from "#mobile/ui/theme";
import { ArticleCard } from "../components/article-card";
import { ArticleCategoryFilter } from "../components/article-category-filter";
import { ArticleListFooter } from "../components/article-list-footer";
import { useInfiniteArticles } from "../hooks/use-infinite-articles";
export function AllArticlesScreen() {
const colors = useAppColors();
const [isRefreshing, setIsRefreshing] = useState(false);
const [search, setSearch] = useState("");
const [selectedCategoryId, setSelectedCategoryId] = useState<string>();
const normalizedSearch = search.trim();
const hasActiveFilters = normalizedSearch.length > 0 || selectedCategoryId !== undefined;
const articleFeed = useInfiniteArticles({
categoryId: selectedCategoryId,
search: normalizedSearch || undefined,
});
async function handleRefresh() {
setIsRefreshing(true);
try {
await articleFeed.refetch();
} finally {
setIsRefreshing(false);
}
}
return (
<>
<Stack.SearchBar
hideWhenScrolling={false}
onChangeText={(event) => setSearch(event.nativeEvent.text)}
placeholder="Rechercher une actualité"
placement="integratedButton"
tintColor={colors.primary}
/>
<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()} />
) : hasActiveFilters ? (
<EmptyState
description="Essayez une autre recherche ou une autre catégorie."
title="Aucune actualité trouvée"
/>
) : (
<EmptyState
description="Les prochaines publications apparaîtront ici."
title="Aucune actualité"
/>
)
}
ListFooterComponent={
<ArticleListFooter
hasError={articleFeed.isFetchNextPageError}
isLoading={articleFeed.isFetchingNextPage}
onRetry={articleFeed.loadNextPage}
/>
}
ListHeaderComponent={
<ArticleCategoryFilter
onChange={setSelectedCategoryId}
selectedCategoryId={selectedCategoryId}
/>
}
ListHeaderComponentStyle={{ marginBottom: 8 }}
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,143 @@
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
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 { useArticleBookmarks } from "#mobile/features/content/bookmarks/hooks/use-article-bookmarks";
import { formatPublicationDate } from "#mobile/features/content/shared/format-publication-date";
import { toPlainText } from "#mobile/features/content/shared/to-plain-text";
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";
export function ArticleDetailsScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const trpc = useTRPC();
const article = useQuery(trpc.feed.articles.get.queryOptions({ id }));
const articleBookmarks = useArticleBookmarks(id);
async function handleShare() {
if (!article.data) {
return;
}
await Share.share({
message: `${article.data.title}\n${article.data.link}`,
title: article.data.title,
url: article.data.link,
});
}
if (article.isPending) {
return (
<Screen hasNativeHeader>
<LoadingState label="Chargement de larticle…" />
</Screen>
);
}
if (article.isError) {
return (
<Screen hasNativeHeader>
<ErrorState onRetry={() => void article.refetch()} />
</Screen>
);
}
return (
<Screen hasNativeHeader>
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button
accessibilityLabel={
articleBookmarks.isBookmarked ? "Gérer les signets" : "Ajouter aux signets"
}
icon={articleBookmarks.isBookmarked ? "bookmark.fill" : "bookmark"}
onPress={() =>
router.push({
params: { articleId: article.data.id },
pathname: "/(app)/(tabs)/articles/bookmark-picker",
})
}
/>
<Stack.Toolbar.Button
accessibilityLabel="Partager larticle"
icon="square.and.arrow.up"
onPress={handleShare}
/>
<Stack.Toolbar.Menu accessibilityLabel="Actions de larticle" icon="ellipsis">
<Stack.Toolbar.MenuAction
icon="bubble.left"
onPress={() =>
router.push({
params: { articleId: article.data.id },
pathname: "/(app)/(tabs)/articles/comments",
})
}
>
Commentaires
</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction
icon="safari"
onPress={() => void Linking.openURL(article.data.link)}
>
Ouvrir sur le site
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
</Stack.Toolbar>
<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>
</YStack>
</ScrollView>
</Screen>
);
}
@@ -0,0 +1,148 @@
import { Stack, 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 { 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";
import { ArticleCard } from "../components/article-card";
import { ArticleCategoryFilter } from "../components/article-category-filter";
import { ArticleListFooter } from "../components/article-list-footer";
import { FeaturedArticleCard } from "../components/featured-article-card";
import { useInfiniteArticles } from "../hooks/use-infinite-articles";
export function ArticlesHomeScreen() {
const colors = useAppColors();
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const [search, setSearch] = useState("");
const [selectedCategoryId, setSelectedCategoryId] = useState<string>();
const normalizedSearch = search.trim();
const hasActiveFilters = normalizedSearch.length > 0 || selectedCategoryId !== undefined;
const articleFeed = useInfiniteArticles({
categoryId: selectedCategoryId,
search: normalizedSearch || undefined,
});
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 (
<>
<Stack.SearchBar
hideWhenScrolling={false}
onChangeText={(event) => setSearch(event.nativeEvent.text)}
placeholder="Rechercher une actualité"
placement="integratedButton"
tintColor={colors.primary}
/>
<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 }}
>
<YStack gap="$4">
<ArticleCategoryFilter
onChange={setSelectedCategoryId}
selectedCategoryId={selectedCategoryId}
/>
{articleFeed.isPending ? (
<LoadingState label="Chargement de lactualité…" />
) : articleFeed.isError && articleItems.length === 0 ? (
<ErrorState onRetry={() => void handleRefresh()} />
) : hasActiveFilters ? (
<YStack gap="$2">
<SectionHeader title="Résultats" />
{articleItems.length === 0 ? (
<EmptyState
description="Essayez une autre recherche ou une autre catégorie."
title="Aucune actualité trouvée"
/>
) : (
articleItems.map((article) => <ArticleCard article={article} key={article.id} />)
)}
<ArticleListFooter
hasError={articleFeed.isFetchNextPageError}
isLoading={articleFeed.isFetchingNextPage}
onRetry={articleFeed.loadNextPage}
/>
</YStack>
) : (
<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>
)}
</YStack>
</ScrollView>
</>
);
}
@@ -1,56 +0,0 @@
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>
);
}
@@ -1,12 +1,12 @@
import { createBookmarkSchema } from "@basango/domain/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { Controller } from "react-hook-form";
import { ScrollView, XStack, YStack } from "tamagui";
import type z from "zod";
import { useZodForm } from "#mobile/application/hooks/use-zod-form";
import { useTRPC } from "#mobile/application/trpc/client";
import type { Bookmark } from "#mobile/features/content/types";
import { Input } from "#mobile/ui/components/input";
@@ -27,10 +27,9 @@ export function BookmarkForm({ bookmark }: BookmarkFormProps) {
const queryClient = useQueryClient();
const router = useRouter();
const trpc = useTRPC();
const form = useForm<BookmarkFormValues>({
const form = useZodForm(bookmarkFormSchema, {
defaultValues: { description: "", isPublic: false, name: "" },
mode: "onChange",
resolver: zodResolver(bookmarkFormSchema),
});
function showError(error: unknown) {
@@ -123,12 +122,6 @@ export function BookmarkForm({ bookmark }: BookmarkFormProps) {
contentInsetAdjustmentBehavior="automatic"
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"
@@ -1,40 +1,26 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookmarkIcon, CheckIcon } from "lucide-react-native";
import { Alert } from "react-native";
import { ScrollView, XStack, YStack } from "tamagui";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, YStack } from "tamagui";
import { useTRPC } from "#mobile/application/trpc/client";
import { EmptyState, LoadingState } from "#mobile/ui/components/status-state";
import { Text } from "#mobile/ui/components/text";
import { useAppColors } from "#mobile/ui/theme";
import { BookmarkRow } from "#mobile/features/content/bookmarks/components/bookmark-row";
import { useArticleBookmarks } from "#mobile/features/content/bookmarks/hooks/use-article-bookmarks";
import { EmptyState, ErrorState, LoadingState } from "#mobile/ui/components/status-state";
type BookmarkPickerProps = {
articleId: string;
onComplete: () => void;
};
export function BookmarkPicker({ articleId, onComplete }: BookmarkPickerProps) {
const colors = useAppColors();
const queryClient = useQueryClient();
export function BookmarkPicker({ articleId }: BookmarkPickerProps) {
const trpc = useTRPC();
const bookmarks = useQuery(trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }));
const addArticle = useMutation(
trpc.feed.bookmarks.addArticle.mutationOptions({
onError(error) {
Alert.alert(
"Ajout impossible",
error.message || "Impossible dajouter cet article au signet.",
);
},
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
void queryClient.invalidateQueries({
queryKey: trpc.feed.bookmarks.listArticles.pathKey(),
});
onComplete();
},
}),
);
const articleBookmarks = useArticleBookmarks(articleId);
const bookmarkItems = bookmarks.data?.items ?? [];
const isPending = bookmarks.isPending || articleBookmarks.isPending;
const isError = bookmarks.isError || articleBookmarks.isError;
function handleRetry() {
void Promise.all([bookmarks.refetch(), articleBookmarks.refetch()]);
}
return (
<ScrollView
@@ -42,47 +28,28 @@ export function BookmarkPicker({ articleId, onComplete }: BookmarkPickerProps) {
contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 20, paddingTop: 12 }}
contentInsetAdjustmentBehavior="automatic"
>
<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 ? (
{isPending ? <LoadingState /> : null}
{!isPending && isError ? <ErrorState onRetry={handleRetry} /> : null}
{!isPending && !isError && bookmarkItems.length === 0 ? (
<EmptyState
description="Créez dabord un signet depuis longlet Signets."
title="Aucun signet"
/>
) : null}
{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>
))}
{!isPending && !isError && bookmarkItems.length > 0 ? (
<YStack backgroundColor="$card" borderRadius="$5" overflow="hidden">
{bookmarkItems.map((bookmark, index) => (
<BookmarkRow
bookmark={bookmark}
disabled={articleBookmarks.isUpdating}
isSelected={articleBookmarks.bookmarkIds.has(bookmark.id)}
key={bookmark.id}
onPress={() => articleBookmarks.toggleBookmark(bookmark.id)}
showSeparator={index < bookmarkItems.length - 1}
/>
))}
</YStack>
) : null}
</ScrollView>
);
}
@@ -0,0 +1,55 @@
import { CheckIcon, ChevronRightIcon } 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 BookmarkRowProps = {
bookmark: Bookmark;
disabled?: boolean;
isSelected?: boolean;
onPress: () => void;
showDisclosure?: boolean;
showSeparator?: boolean;
};
export function BookmarkRow({
bookmark,
disabled = false,
isSelected = false,
onPress,
showDisclosure = false,
showSeparator = false,
}: BookmarkRowProps) {
const colors = useAppColors();
return (
<XStack
accessibilityRole="button"
accessibilityState={{ disabled, selected: isSelected }}
alignItems="center"
borderBottomColor="$separator"
borderBottomWidth={showSeparator ? StyleSheet.hairlineWidth : 0}
disabled={disabled}
minHeight={62}
onPress={onPress}
paddingHorizontal="$4"
paddingVertical="$3"
pressStyle={{ opacity: 0.72 }}
>
<YStack flex={1} gap="$1">
<Text numberOfLines={1} variant="title">
{bookmark.name}
</Text>
<Text variant="caption">{bookmark.articlesCount} articles</Text>
</YStack>
{isSelected ? (
<CheckIcon color={colors.primary} size={21} strokeWidth={2.2} />
) : showDisclosure ? (
<ChevronRightIcon color={colors.muted} size={20} strokeWidth={1.8} />
) : null}
</XStack>
);
}
@@ -0,0 +1,167 @@
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { Alert } from "react-native";
import { useTRPC } from "#mobile/application/trpc/client";
type ArticleBookmarkMemberships = RouterOutputs["feed"]["bookmarks"]["memberships"];
type BookmarkList = RouterOutputs["feed"]["bookmarks"]["list"];
type BookmarkListSnapshot = [readonly unknown[], BookmarkList | undefined][];
type BookmarkRollbackState = {
bookmarkLists: BookmarkListSnapshot;
memberships: ArticleBookmarkMemberships | undefined;
};
export function useArticleBookmarks(articleId: string) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const membershipsQueryOptions = trpc.feed.bookmarks.memberships.queryOptions({ id: articleId });
const memberships = useQuery(membershipsQueryOptions);
const addRollbackState = useRef<BookmarkRollbackState | undefined>(undefined);
const removeRollbackState = useRef<BookmarkRollbackState | undefined>(undefined);
function updateMembership(bookmarkId: string, isSaved: boolean) {
queryClient.setQueryData<ArticleBookmarkMemberships>(
membershipsQueryOptions.queryKey,
(current) => {
const bookmarkIds = new Set(current?.bookmarkIds ?? []);
if (isSaved) {
bookmarkIds.add(bookmarkId);
} else {
bookmarkIds.delete(bookmarkId);
}
return { bookmarkIds: [...bookmarkIds] };
},
);
}
function updateBookmarkCount(bookmarkId: string, difference: 1 | -1) {
queryClient.setQueriesData<BookmarkList>(trpc.feed.bookmarks.list.queryFilter(), (current) => {
if (!current) {
return current;
}
return {
...current,
items: current.items.map((bookmark) =>
bookmark.id === bookmarkId
? {
...bookmark,
articlesCount: Math.max(0, bookmark.articlesCount + difference),
}
: bookmark,
),
};
});
}
function invalidateBookmarkQueries() {
void queryClient.invalidateQueries({ queryKey: membershipsQueryOptions.queryKey });
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
void queryClient.invalidateQueries({
queryKey: trpc.feed.bookmarks.listArticles.pathKey(),
});
}
function restoreBookmarkState(state: BookmarkRollbackState | undefined) {
if (!state) {
return;
}
queryClient.setQueryData(membershipsQueryOptions.queryKey, state.memberships);
state.bookmarkLists.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
}
const addArticle = useMutation({
...trpc.feed.bookmarks.addArticle.mutationOptions(),
onError(error) {
restoreBookmarkState(addRollbackState.current);
Alert.alert(
"Ajout impossible",
error.message || "Impossible dajouter cet article au signet.",
);
},
async onMutate(input) {
await Promise.all([
queryClient.cancelQueries({ queryKey: membershipsQueryOptions.queryKey }),
queryClient.cancelQueries(trpc.feed.bookmarks.list.queryFilter()),
]);
addRollbackState.current = {
bookmarkLists: queryClient.getQueriesData<BookmarkList>(
trpc.feed.bookmarks.list.queryFilter(),
),
memberships: queryClient.getQueryData<ArticleBookmarkMemberships>(
membershipsQueryOptions.queryKey,
),
};
updateMembership(input.bookmarkId, true);
updateBookmarkCount(input.bookmarkId, 1);
return undefined;
},
onSettled: invalidateBookmarkQueries,
});
const removeArticle = useMutation({
...trpc.feed.bookmarks.removeArticle.mutationOptions(),
onError(error) {
restoreBookmarkState(removeRollbackState.current);
Alert.alert(
"Retrait impossible",
error.message || "Impossible de retirer cet article du signet.",
);
},
async onMutate(input) {
await Promise.all([
queryClient.cancelQueries({ queryKey: membershipsQueryOptions.queryKey }),
queryClient.cancelQueries(trpc.feed.bookmarks.list.queryFilter()),
]);
removeRollbackState.current = {
bookmarkLists: queryClient.getQueriesData<BookmarkList>(
trpc.feed.bookmarks.list.queryFilter(),
),
memberships: queryClient.getQueryData<ArticleBookmarkMemberships>(
membershipsQueryOptions.queryKey,
),
};
updateMembership(input.bookmarkId, false);
updateBookmarkCount(input.bookmarkId, -1);
return undefined;
},
onSettled: invalidateBookmarkQueries,
});
const bookmarkIds = new Set(memberships.data?.bookmarkIds ?? []);
const isUpdating = addArticle.isPending || removeArticle.isPending;
function toggleBookmark(bookmarkId: string) {
if (isUpdating) {
return;
}
if (bookmarkIds.has(bookmarkId)) {
removeArticle.mutate({ articleId, bookmarkId });
return;
}
addArticle.mutate({ articleId, bookmarkId });
}
return {
bookmarkIds,
isBookmarked: bookmarkIds.size > 0,
isError: memberships.isError,
isPending: memberships.isPending,
isUpdating,
refetch: memberships.refetch,
toggleBookmark,
};
}
@@ -0,0 +1,26 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { BookmarkPicker } from "#mobile/features/content/bookmarks/components/bookmark-picker";
import { ErrorState } from "#mobile/ui/components/status-state";
export function ArticleBookmarkPickerScreen() {
const { articleId } = useLocalSearchParams<{ articleId?: string }>();
const router = useRouter();
return (
<>
<Stack.Toolbar placement="left">
<Stack.Toolbar.Button
accessibilityLabel="Fermer"
icon="xmark"
onPress={() => router.back()}
/>
</Stack.Toolbar>
{articleId ? (
<BookmarkPicker articleId={articleId} />
) : (
<ErrorState description="Cet article est introuvable." />
)}
</>
);
}
@@ -0,0 +1,162 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { Trash2Icon } from "lucide-react-native";
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 { useInfiniteBookmarkArticles } from "#mobile/features/content/bookmarks/hooks/use-infinite-bookmark-articles";
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 function BookmarkDetailsScreen() {
const colors = useAppColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const queryClient = useQueryClient();
const trpc = useTRPC();
const bookmarks = useQuery(trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }));
const articles = useInfiniteBookmarkArticles(id);
const deleteBookmark = useMutation(
trpc.feed.bookmarks.delete.mutationOptions({
onSuccess() {
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
router.back();
},
}),
);
const removeArticle = useMutation(
trpc.feed.bookmarks.removeArticle.mutationOptions({
onSuccess() {
void articles.refetch();
void queryClient.invalidateQueries(trpc.feed.bookmarks.list.queryFilter());
},
}),
);
const bookmark = bookmarks.data?.items.find((item) => item.id === id);
function handleDeleteBookmark() {
Alert.alert("Supprimer ce signet ?", "La collection sera supprimée, pas ses articles.", [
{ style: "cancel", text: "Annuler" },
{
onPress: () => deleteBookmark.mutate({ id }),
style: "destructive",
text: "Supprimer",
},
]);
}
function handleRemoveArticle(articleId: string) {
Alert.alert("Retirer larticle ?", "Il ne figurera plus dans cette collection.", [
{ style: "cancel", text: "Annuler" },
{
onPress: () => removeArticle.mutate({ articleId, bookmarkId: id }),
style: "destructive",
text: "Retirer",
},
]);
}
if (bookmarks.isPending || articles.isPending) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<LoadingState label="Chargement du signet…" />
</Screen>
);
}
if (bookmarks.isError || (articles.isError && articles.articles.length === 0) || !bookmark) {
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<ErrorState onRetry={() => void Promise.all([bookmarks.refetch(), articles.refetch()])} />
</Screen>
);
}
return (
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
<Stack.Title style={{ color: colors.foreground }}>{bookmark.name}</Stack.Title>
<Stack.Toolbar placement="right">
<Stack.Toolbar.Menu accessibilityLabel="Actions du signet" icon="ellipsis">
<Stack.Toolbar.MenuAction
icon="pencil"
onPress={() =>
router.push({
params: { bookmarkId: bookmark.id },
pathname: "/(app)/(tabs)/bookmarks/form",
})
}
>
Modifier
</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction
destructive
disabled={deleteBookmark.isPending}
icon="trash"
onPress={handleDeleteBookmark}
>
Supprimer
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
</Stack.Toolbar>
<FlatList
contentContainerStyle={{
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
data={articles.articles}
ItemSeparatorComponent={() => <YStack height="$2" />}
keyExtractor={(article) => article.id}
ListEmptyComponent={
<EmptyState
description="Ouvrez un article et touchez licône de signet pour lajouter ici."
title="Collection vide"
/>
}
ListFooterComponent={
<ArticleListFooter
hasError={articles.isFetchNextPageError}
isLoading={articles.isFetchingNextPage}
onRetry={articles.loadNextPage}
/>
}
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 }}
/>
</Screen>
);
}
@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useTRPC } from "#mobile/application/trpc/client";
import { BookmarkForm } from "#mobile/features/content/bookmarks/components/bookmark-form";
import { ErrorState, LoadingState } from "#mobile/ui/components/status-state";
export function BookmarkFormScreen() {
const { bookmarkId } = useLocalSearchParams<{ bookmarkId?: string }>();
const trpc = useTRPC();
const bookmarks = useQuery({
...trpc.feed.bookmarks.list.queryOptions({ limit: 100, page: 1 }),
enabled: Boolean(bookmarkId),
});
if (bookmarkId && bookmarks.isPending) {
return <LoadingState label="Chargement du signet…" />;
}
if (bookmarkId && bookmarks.isError) {
return <ErrorState onRetry={() => void bookmarks.refetch()} />;
}
const bookmark = bookmarks.data?.items.find((item) => item.id === bookmarkId);
if (bookmarkId && !bookmark) {
return <ErrorState description="Ce signet est introuvable." />;
}
return <BookmarkForm bookmark={bookmark} />;
}
@@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useRouter } 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 { BookmarkRow } from "#mobile/features/content/bookmarks/components/bookmark-row";
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 function BookmarksScreen() {
const colors = useAppColors();
const router = useRouter();
const trpc = useTRPC();
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.Toolbar placement="right">
<Stack.Toolbar.Button
accessibilityLabel="Créer un signet"
icon="plus"
onPress={() => router.push("/(app)/(tabs)/bookmarks/form")}
/>
</Stack.Toolbar>
<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) => (
<BookmarkRow
bookmark={bookmark}
key={bookmark.id}
onPress={() =>
router.push({
params: { id: bookmark.id },
pathname: "/(app)/(tabs)/bookmarks/[id]",
})
}
showDisclosure
showSeparator={index < bookmarkItems.length - 1}
/>
))}
</YStack>
)}
</ScrollView>
</>
);
}
@@ -1,13 +1,12 @@
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 { Controller } 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 { useZodForm } from "#mobile/application/hooks/use-zod-form";
import { useTRPC } from "#mobile/application/trpc/client";
import { formatRelativeTime } from "#mobile/features/content/shared/format-relative-time";
import { Button } from "#mobile/ui/components/button";
@@ -20,8 +19,6 @@ import { useAppColors } from "#mobile/ui/theme";
const commentFormSchema = createCommentSchema.pick({ content: true });
type CommentForm = z.infer<typeof commentFormSchema>;
type ArticleCommentsProps = {
articleId: string;
enabled: boolean;
@@ -36,10 +33,9 @@ export function ArticleComments({ articleId, enabled }: ArticleCommentsProps) {
...trpc.feed.comments.list.queryOptions({ articleId, limit: 50, page: 1 }),
enabled,
});
const form = useForm<CommentForm>({
const form = useZodForm(commentFormSchema, {
defaultValues: { content: "" },
mode: "onChange",
resolver: zodResolver(commentFormSchema),
});
const createComment = useMutation(
trpc.feed.comments.create.mutationOptions({
@@ -0,0 +1,26 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { ArticleComments } from "#mobile/features/content/comments/components/article-comments";
import { ErrorState } from "#mobile/ui/components/status-state";
export function ArticleCommentsScreen() {
const { articleId } = useLocalSearchParams<{ articleId?: string }>();
const router = useRouter();
return (
<>
<Stack.Toolbar placement="left">
<Stack.Toolbar.Button
accessibilityLabel="Fermer"
icon="xmark"
onPress={() => router.back()}
/>
</Stack.Toolbar>
{articleId ? (
<ArticleComments articleId={articleId} enabled />
) : (
<ErrorState description="Cet article est introuvable." />
)}
</>
);
}
@@ -0,0 +1,146 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import { FlatList, Linking, RefreshControl } 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 { useSourceFollowAction } from "#mobile/features/content/sources/hooks/use-source-follow-action";
import type { Source } from "#mobile/features/content/types";
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 function SourceDetailsScreen() {
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.Title style={{ color: colors.foreground }}>{displayName}</Stack.Title>
<SourceToolbar source={source.data} />
<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>
</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>
);
}
type SourceToolbarProps = {
source: Source;
};
function SourceToolbar({ source }: SourceToolbarProps) {
const followAction = useSourceFollowAction(source);
return (
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button
accessibilityLabel={source.followed ? "Ne plus suivre" : "Suivre"}
disabled={followAction.isPending}
icon={source.followed ? "checkmark" : "plus"}
onPress={followAction.toggleFollow}
/>
<Stack.Toolbar.Button
accessibilityLabel="Ouvrir le site"
icon="safari"
onPress={() => void Linking.openURL(source.url)}
/>
</Stack.Toolbar>
);
}
@@ -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 function SourcesScreen() {
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,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 function AccountScreen() {
const { preference, resolvedScheme, setPreference } = useAppearance();
const colors = useAppColors();
const session = authClient.useSession();
const user = session.data?.user;
async function handleSignOut() {
const result = await authClient.signOut();
if (result.error) {
Alert.alert("Déconnexion impossible", result.error.message ?? "Réessayez dans un instant.");
}
}
function confirmSignOut() {
Alert.alert("Se déconnecter ?", "Vous devrez vous reconnecter pour accéder à votre compte.", [
{ style: "cancel", text: "Annuler" },
{ onPress: () => void handleSignOut(), style: "destructive", text: "Se déconnecter" },
]);
}
function chooseAppearance() {
const preferences: AppearancePreference[] = ["system", "light", "dark"];
const options = preferences.map((option) =>
option === preference ? `${appearanceLabels[option]}` : appearanceLabels[option],
);
ActionSheetIOS.showActionSheetWithOptions(
{
cancelButtonIndex: options.length,
options: [...options, "Annuler"],
title: "Apparence",
userInterfaceStyle: resolvedScheme,
},
(selectedIndex) => {
const selectedPreference = preferences[selectedIndex];
if (selectedPreference) {
setPreference(selectedPreference);
}
},
);
}
return (
<ScrollView
contentContainerStyle={{
gap: sectionGap,
paddingBottom: screenBottomPadding,
paddingHorizontal: screenGutter,
}}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
style={{ backgroundColor: colors.groupedBackground, flex: 1 }}
>
{!user ? (
<LoadingState label="Chargement du profil…" />
) : (
<>
<GroupedSection>
<XStack alignItems="center" gap="$4" minHeight={88} padding="$4">
<SourceAvatar name={user.name} size="medium" />
<YStack flex={1} gap="$0.5">
<Text fontSize="$6" fontWeight="600" numberOfLines={1}>
{user.name}
</Text>
<Text numberOfLines={1} variant="caption">
{user.email}
</Text>
</YStack>
</XStack>
</GroupedSection>
<GroupedSection title="Informations personnelles">
<GroupedRow
icon={
<GroupedIcon>
<UserRoundIcon color="white" size={18} strokeWidth={1.9} />
</GroupedIcon>
}
label="Nom"
showSeparator
value={user.name}
/>
<GroupedRow
icon={
<GroupedIcon>
<MailIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Adresse e-mail"
showSeparator
value={user.email}
/>
<GroupedRow
icon={
<GroupedIcon>
<ShieldCheckIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Compte"
value={user.emailVerified ? "E-mail vérifié" : "E-mail non vérifié"}
/>
</GroupedSection>
<GroupedSection title="Apparence">
<GroupedRow
accessibilityHint="Choisit le thème système, clair ou sombre"
icon={
<GroupedIcon>
<SunMoonIcon color="white" size={17} strokeWidth={1.9} />
</GroupedIcon>
}
label="Thème"
onPress={chooseAppearance}
value={appearanceLabels[preference]}
/>
</GroupedSection>
<GroupedSection>
<GroupedRow
accessibilityHint="Ferme la session sur cet appareil"
destructive
label="Se déconnecter"
onPress={confirmSignOut}
/>
</GroupedSection>
<Text textAlign="center" variant="caption">
Basango · Lactualité qui vous rapproche
</Text>
</>
)}
</ScrollView>
);
}
@@ -0,0 +1,105 @@
import { requestPasswordResetSchema } from "@basango/domain/models";
import { Link } from "expo-router";
import { Controller } from "react-hook-form";
import { YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { useZodForm } from "#mobile/application/hooks/use-zod-form";
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 function ForgotPasswordScreen() {
const form = useZodForm(requestPasswordResetSchema, {
defaultValues: { email: "" },
mode: "onChange",
});
const message = form.formState.errors.root?.message;
async function handleRequest(values: ForgotPasswordForm) {
form.clearErrors("root");
const result = await authClient.requestPasswordReset({
email: values.email,
redirectTo: "basango://reset-password",
});
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(
result.error,
"Impossible denvoyer le lien de réinitialisation.",
),
});
return;
}
form.setError("root", {
message: "Si cette adresse est associée à un compte, un lien vient de vous être envoyé.",
type: "success",
});
}
const isSuccess = form.formState.errors.root?.type === "success";
return (
<KeyboardScreen hasNativeHeader>
<YStack
flex={1}
gap="$4"
justifyContent="space-between"
paddingBottom="$6"
paddingHorizontal="$4"
paddingTop="$5"
>
<YStack flex={1} gap="$4">
<Text>
Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation de mot de
passe.
</Text>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
{message ? (
<Text color={isSuccess ? "$primary" : "$danger"} variant="caption">
{message}
</Text>
) : null}
<Link asChild href="/(auth)/sign-in">
<Text>Vous avez déjà un compte ? Se connecter</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleRequest)}
>
Réinitialiser le mot de passe
</Button>
</YStack>
</KeyboardScreen>
);
}
@@ -0,0 +1,112 @@
import { resetPasswordSchema } from "@basango/domain/models";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Controller } from "react-hook-form";
import { YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { useZodForm } from "#mobile/application/hooks/use-zod-form";
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";
import { getAuthErrorMessage } from "../auth-error";
type ResetPasswordForm = z.infer<typeof resetPasswordSchema>;
export function ResetPasswordScreen() {
const params = useLocalSearchParams<{ token?: string | string[] }>();
const router = useRouter();
const token = firstParam(params.token);
const form = useZodForm(resetPasswordSchema, {
defaultValues: { confirmPassword: "", password: "" },
mode: "onChange",
});
const error = form.formState.errors.root?.message;
async function handleReset(values: ResetPasswordForm) {
if (!token) {
form.setError("root", { message: "Ce lien de réinitialisation est incomplet." });
return;
}
form.clearErrors("root");
const result = await authClient.resetPassword({ newPassword: values.password, token });
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(result.error, "Ce lien est invalide ou a expiré."),
});
return;
}
router.replace("/(auth)/sign-in");
}
return (
<KeyboardScreen hasNativeHeader>
<YStack
flex={1}
gap="$4"
justifyContent="space-between"
paddingBottom="$6"
paddingHorizontal="$4"
paddingTop="$5"
>
<YStack flex={1} gap="$4">
<Text>Choisissez un mot de passe dau moins huit caractères.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Nouveau mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="confirmPassword"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Confirmer le mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
</YStack>
<Button
disabled={!form.formState.isValid || !token}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleReset)}
>
Enregistrer le mot de passe
</Button>
</YStack>
</KeyboardScreen>
);
}
function firstParam(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@@ -0,0 +1,121 @@
import { loginSchema } from "@basango/domain/models";
import { Link } from "expo-router";
import { Controller } from "react-hook-form";
import { ScrollView, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { useZodForm } from "#mobile/application/hooks/use-zod-form";
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 function SignInScreen() {
const form = useZodForm(loginSchema, {
defaultValues: { email: "", password: "" },
mode: "onChange",
});
const error = form.formState.errors.root?.message;
async function handleSignIn(values: LoginForm) {
form.clearErrors("root");
const result = await authClient.signIn.email(values);
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(
result.error,
"Vérifiez votre adresse e-mail et votre mot de passe.",
),
});
}
}
return (
<KeyboardScreen hasNativeHeader>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
gap: 16,
justifyContent: "space-between",
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 20,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<YStack flex={1} gap="$4">
<Text>Bienvenue sur Basango, la plateforme dactualités intelligente.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
error={fieldState.error?.message}
label="Mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Votre mot de passe"
value={field.value}
/>
)}
/>
<Link asChild href="/(auth)/forgot-password">
<Text color="$primary">Mot de passe oublié ?</Text>
</Link>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
<Text variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez
avoir lu notre politique de confidentialité.
</Text>
<Link asChild href="/(auth)/sign-up">
<Text>Vous navez pas de compte ? Créer un compte</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSignIn)}
>
Se connecter
</Button>
</ScrollView>
</KeyboardScreen>
);
}
@@ -0,0 +1,130 @@
import { signUpSchema } from "@basango/domain/models";
import { Link } from "expo-router";
import { Controller } from "react-hook-form";
import { ScrollView, YStack } from "tamagui";
import type z from "zod";
import { authClient } from "#mobile/application/auth/auth-client";
import { useZodForm } from "#mobile/application/hooks/use-zod-form";
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 function SignUpScreen() {
const form = useZodForm(signUpSchema, {
defaultValues: { email: "", name: "", password: "" },
mode: "onChange",
});
const error = form.formState.errors.root?.message;
async function handleSignUp(values: SignUpForm) {
form.clearErrors("root");
const result = await authClient.signUp.email(values);
if (result.error) {
form.setError("root", {
message: getAuthErrorMessage(result.error, "Impossible de créer votre compte."),
});
}
}
return (
<KeyboardScreen hasNativeHeader>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
gap: 16,
justifyContent: "space-between",
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 20,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<YStack flex={1} gap="$4">
<Text>Rejoignez la communauté Basango et restez informé des dernières actualités.</Text>
<YStack gap="$2">
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Input
autoComplete="name"
error={fieldState.error?.message}
label="Nom complet"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="Votre nom"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Input
autoCapitalize="none"
autoComplete="email"
error={fieldState.error?.message}
keyboardType="email-address"
label="Adresse e-mail"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="vous@exemple.com"
value={field.value}
/>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<PasswordInput
autoComplete="new-password"
error={fieldState.error?.message}
label="Mot de passe"
onBlur={field.onBlur}
onChangeText={field.onChange}
placeholder="8 caractères minimum"
value={field.value}
/>
)}
/>
{error ? (
<Text color="$danger" variant="caption">
{error}
</Text>
) : null}
</YStack>
<Text variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez
avoir lu notre politique de confidentialité.
</Text>
<Link asChild href="/(auth)/sign-in">
<Text>Vous avez un compte ? Connectez-vous</Text>
</Link>
</YStack>
<Button
disabled={!form.formState.isValid}
isLoading={form.formState.isSubmitting}
onPress={form.handleSubmit(handleSignUp)}
>
Créer un compte
</Button>
</ScrollView>
</KeyboardScreen>
);
}
@@ -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 function WelcomeScreen() {
const router = useRouter();
return (
<Screen alignItems="center" gap="$4" justifyContent="center" paddingHorizontal="$4">
<LogoMark />
<YStack gap="$6" width="100%">
<YStack gap="$3">
<Text textAlign="center" variant="display">
Bienvenue sur Basango
</Text>
<Text lineHeight="$1" marginTop="auto" textAlign="center">
La première plateforme dactualités intelligente qui vous aide à rester informé sur
lactualité congolaise et internationale.
</Text>
</YStack>
<YStack gap="$4">
<Button onPress={() => router.push("/(auth)/sign-in")}>Se connecter</Button>
<Text onPress={() => router.push("/(auth)/sign-up")} textAlign="center">
Ouvrir un compte
</Text>
</YStack>
<Text textAlign="center" variant="caption">
En continuant, vous acceptez les conditions dutilisation de Basango et reconnaissez avoir
lu notre politique de confidentialité.
</Text>
</YStack>
</Screen>
);
}
+10 -6
View File
@@ -70,7 +70,7 @@ Organize growing application code by bounded context and product capability befo
```text
src/features/<context>/<capability>/
pages/
pages/ or screens/
components/
hooks/
<capability>-query.ts
@@ -78,9 +78,9 @@ src/features/<context>/<capability>/
<capability>-copy.ts
```
This is a target shape, not a reason for a repository-wide move. Existing top-level `components/`, `hooks/`, and route
modules may evolve capability by capability. Keep a private single-consumer component or hook beside its owner when
that improves locality.
Dashboard capabilities use `pages/`; mobile capabilities use `screens/`. This is a target shape, not a reason for a
repository-wide move. Existing top-level `components/`, `hooks/`, and route modules may evolve capability by
capability. Keep a private single-consumer component or hook beside its owner when that improves locality.
Route modules should compose feature interfaces and own route concerns. They should not accumulate reusable business
logic, cache policy, large presentation trees, or transport normalization.
@@ -102,12 +102,16 @@ The mobile reader mirrors those ownership boundaries with Expo-specific names:
```text
apps/mobile/src/
app/ # Expo Router route modules only
application/ # native auth, providers, environment, and typed tRPC client
app/ # Expo Router screen re-exports and framework-owned navigation layouts
application/ # native auth, providers, environment, app-level screens, and typed tRPC client
features/ # reader capabilities and product components
ui/ # app-local native primitives and theme helpers
```
Mobile screen route modules do not define React components; each directly re-exports a feature- or application-owned
screen so product state, queries, and presentation stay outside the routing tree. Expo Router `_layout.tsx` modules
are the framework-level exception and own their navigator composition in place.
## Package interfaces
Cross a package seam only through an entry declared in that package's `exports` map. Use relative imports inside the
+5 -4
View File
@@ -245,7 +245,7 @@ Organize growing application code by bounded context and capability:
```text
src/features/<context>/<capability>/
pages/
pages/ or screens/
components/
hooks/
<capability>-query.ts
@@ -253,9 +253,10 @@ src/features/<context>/<capability>/
<capability>-copy.ts
```
Use `pages/`, `components/`, and `hooks/` for exported modules in those roles. Private single-consumer modules may stay
beside their owner. Keep a schema, query module, or copy module at the capability root until it forms a real group.
Split a growing capability by narrower product responsibility rather than creating a generic dumping ground.
Dashboard capabilities use `pages/`; mobile capabilities use `screens/`. Use `components/` and `hooks/` for exported
modules in those roles. Private single-consumer modules may stay beside their owner. Keep a schema, query module, or
copy module at the capability root until it forms a real group. Split a growing capability by narrower product
responsibility rather than creating a generic dumping ground.
Prefer responsibility names over vague modules:
+3 -2
View File
@@ -17,7 +17,7 @@ import { Database } from "#db/client";
import { NotFoundError } from "#db/errors";
import { getOrCreateSourceIdByName } from "#db/queries/sources";
import { articles, categories, sources } from "#db/schema";
import { classifyCategory, ensureCanonicalCategory } from "#db/services/category-classifier";
import { classifyArticleCategory } from "#db/services/category-classifier";
import { CreateArticleParams, GetArticlesParams } from "#db/types/articles";
import { GetDistributionsParams, GetPublicationsParams } from "#db/types/shared";
import {
@@ -49,8 +49,9 @@ export async function createArticle(db: Database, params: CreateArticleParams) {
}),
};
const category = await ensureCanonicalCategory(db, classifyCategory(data).category);
const category = await classifyArticleCategory(db, data);
data.categoryId = category.id;
data.clustered = true;
const [result] = await db
.insert(articles)
+175 -5
View File
@@ -1,10 +1,180 @@
import { asc, desc } from "drizzle-orm";
import { DEFAULT_CATEGORY } from "@basango/domain/constants";
import type { CreateCategory, ID, UpdateCategory } from "@basango/domain/models";
import { asc, count, desc, eq, getTableColumns, sql } from "drizzle-orm";
import * as uuid from "uuid";
import { Database } from "#db/client";
import { categories } from "#db/schema";
import type { Database } from "#db/client";
import { NotFoundError } from "#db/errors";
import { articles, categories } from "#db/schema";
import { normalizeCategory } from "#db/services/category-classifier";
const UNKNOWN_CANDIDATE_LIMIT = 12;
export async function getCategories(db: Database) {
return db.query.categories.findMany({
orderBy: [desc(categories.weight), asc(categories.name)],
return db
.select({
...getTableColumns(categories),
articleCount: count(articles.id),
})
.from(categories)
.leftJoin(articles, eq(articles.categoryId, categories.id))
.groupBy(categories.id)
.orderBy(desc(categories.weight), asc(categories.name));
}
export async function createCategory(db: Database, params: CreateCategory) {
return db.transaction(async (tx) => {
const [created] = await tx
.insert(categories)
.values({
...params,
candidates: normalizeCandidates(params.candidates),
id: uuid.v7(),
})
.returning();
if (!created) {
throw new Error("Category could not be created.");
}
await tx.update(articles).set({ clustered: false, updatedAt: sql`now()` });
return created;
});
}
export async function updateCategory(db: Database, params: UpdateCategory) {
return db.transaction(async (tx) => {
const existing = await tx.query.categories.findFirst({
where: eq(categories.id, params.id),
});
if (!existing) {
throw new NotFoundError("Category not found");
}
const candidates = normalizeCandidates(params.candidates);
const [updated] = await tx
.update(categories)
.set({
candidates,
description: params.description,
name: params.name,
slug: params.slug,
updatedAt: new Date(),
weight: params.weight,
})
.where(eq(categories.id, params.id))
.returning();
const classificationChanged =
existing.slug !== params.slug ||
existing.weight !== params.weight ||
!sameCandidates(existing.candidates, candidates);
if (classificationChanged) {
await tx.update(articles).set({ clustered: false, updatedAt: sql`now()` });
}
return updated;
});
}
export async function deleteCategory(db: Database, id: ID) {
return db.transaction(async (tx) => {
const [countRow] = await tx.select({ value: count(categories.id) }).from(categories);
if ((countRow?.value ?? 0) <= 1) {
throw new Error("The last category cannot be deleted.");
}
const [deleted] = await tx.delete(categories).where(eq(categories.id, id)).returning();
if (!deleted) {
throw new NotFoundError("Category not found");
}
await tx.update(articles).set({ clustered: false, updatedAt: sql`now()` });
return deleted;
});
}
export async function getClusteringStats(db: Database) {
const [summaryRows, categoryRows, rawCandidateRows] = await Promise.all([
db
.select({
clustered: sql<number>`count(*) filter (where ${articles.clustered} = true)::int`,
pending: sql<number>`count(*) filter (where ${articles.clustered} = false)::int`,
total: sql<number>`count(*)::int`,
unassigned: sql<number>`count(*) filter (where ${articles.categoryId} is null)::int`,
})
.from(articles),
db
.select({
articleCount: count(articles.id),
candidates: categories.candidates,
id: categories.id,
name: categories.name,
slug: categories.slug,
})
.from(categories)
.leftJoin(articles, eq(articles.categoryId, categories.id))
.groupBy(categories.id)
.orderBy(desc(count(articles.id)), asc(categories.name)),
db.execute<{ candidate: string; count: number }>(sql`
select trim(source_category) as candidate, count(*)::int as count
from ${articles}
cross join lateral unnest(coalesce(${articles.categories}, array[]::text[])) as source_category
where trim(source_category) <> ''
group by trim(source_category)
order by count(*) desc, trim(source_category) asc
`),
]);
const summary = summaryRows[0] ?? { clustered: 0, pending: 0, total: 0, unassigned: 0 };
const knownCandidates = new Set(
categoryRows.flatMap((category) =>
category.candidates
.map((candidate) => normalizeCategory(candidate))
.filter((candidate): candidate is string => Boolean(candidate)),
),
);
const unknownCandidates = rawCandidateRows.rows
.filter((row) => {
const normalized = normalizeCategory(row.candidate);
return normalized !== null && !knownCandidates.has(normalized);
})
.slice(0, UNKNOWN_CANDIDATE_LIMIT);
const fallbackCategory = categoryRows.find((category) => category.slug === DEFAULT_CATEGORY);
const clusteringPercent =
summary.total === 0 ? 0 : Math.round((summary.clustered / summary.total) * 100);
return {
...summary,
categories: categoryRows.map(({ candidates: _candidates, ...category }) => category),
clusteringPercent,
fallbackAssignments: fallbackCategory?.articleCount ?? 0,
unknownCandidates,
};
}
function normalizeCandidates(values: readonly string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
}
function sameCandidates(left: readonly string[], right: readonly string[]): boolean {
const normalizedLeft = left
.map((value) => normalizeCategory(value))
.filter((value): value is string => value !== null)
.sort();
const normalizedRight = right
.map((value) => normalizeCategory(value))
.filter((value): value is string => value !== null)
.sort();
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((value, index) => value === normalizedRight[index])
);
}
+17
View File
@@ -129,6 +129,23 @@ export async function getReaderBookmarkArticles(
return buildPaginatedResult(rows, pagination, total);
}
export async function getReaderArticleBookmarkMemberships(
db: Database,
userId: string,
articleId: string,
) {
const rows = await db
.select({ bookmarkId: bookmarkArticles.bookmarkId })
.from(bookmarkArticles)
.innerJoin(
bookmarks,
and(eq(bookmarks.id, bookmarkArticles.bookmarkId), eq(bookmarks.userId, userId)),
)
.where(eq(bookmarkArticles.articleId, articleId));
return { bookmarkIds: rows.map((row) => row.bookmarkId) };
}
export async function addReaderArticleToBookmark(
db: Database,
userId: string,
+1
View File
@@ -6,6 +6,7 @@ export {
addReaderArticleToBookmark,
createReaderBookmark,
deleteReaderBookmark,
getReaderArticleBookmarkMemberships,
getReaderBookmarkArticles,
getReaderBookmarks,
removeReaderArticleFromBookmark,
+69 -79
View File
@@ -1,34 +1,33 @@
import { logger } from "@basango/logger";
import { desc, eq, inArray, sql } from "drizzle-orm";
import { asc, desc, eq, sql } from "drizzle-orm";
import { Database } from "#db/client";
import type { Database } from "#db/client";
import { articles, categories } from "#db/schema";
import { DEFAULT_CATEGORY } from "#domain/constants";
import { Categories } from "#domain/models";
type CategoryRow = typeof categories.$inferSelect;
type CanonicalCategory = (typeof Categories)[number];
type ArticleCategories = Pick<typeof articles.$inferSelect, "categories" | "id">;
type ClassifierCategory = Pick<CategoryRow, "candidates" | "id" | "name" | "slug" | "weight">;
type CategoryScore = {
category: (typeof Categories)[number];
category: ClassifierCategory;
matches: number;
score: number;
};
const BATCH_SIZE = 50_000;
const CATEGORY_MAP = new Map(Categories.map((category, index) => [category.slug, index]));
const CANDIDATE_MAP = buildCandidateMap();
const FALLBACK_CATEGORY = Categories.find((category) => category.slug === DEFAULT_CATEGORY)!;
export class CategoryClassifier {
constructor(private readonly db: Database) {}
async classifyPendingArticles(limit: number = BATCH_SIZE) {
const canonical = await this.ensureCanonicalCategories();
const configured = await getClassificationCategories(this.db);
const categoryMap = new Map(configured.map((category) => [category.slug, category]));
if (canonical.size === 0) {
logger.warn("No canonical categories available for clustering");
if (categoryMap.size === 0) {
logger.warn("No categories available for clustering");
return { matched: 0, processed: 0, unmatched: 0 };
}
@@ -50,12 +49,9 @@ export class CategoryClassifier {
let matched = 0;
let unmatched = 0;
const fallbackRow = canonical.get(FALLBACK_CATEGORY.slug);
for (const article of pending) {
const best = classifyCategory(article);
const targetRow = canonical.get(best.category.slug) ?? fallbackRow;
const best = classifyCategory(article, configured);
const targetRow = categoryMap.get(best.category.slug);
await this.db
.update(articles)
@@ -87,71 +83,33 @@ export class CategoryClassifier {
logger.info({ matched, processed, unmatched }, "Category clustering run completed");
return { matched, processed, unmatched };
}
private async ensureCanonicalCategories(): Promise<Map<string, CategoryRow>> {
const payload = Categories.map(
(category) =>
({
candidates: category.candidates,
description: category.description ?? null,
embeddings: null,
id: category.id,
name: category.name,
slug: category.slug,
weight: category.weight,
}) satisfies typeof categories.$inferInsert,
);
await this.db.insert(categories).values(payload).onConflictDoNothing();
const existing = await this.db.query.categories.findMany({
where: inArray(
categories.slug,
Categories.map((category) => category.slug),
),
});
const map = new Map<string, CategoryRow>();
for (const row of existing) {
map.set(row.slug, row);
}
if (!map.has(FALLBACK_CATEGORY.slug)) {
logger.warn("Fallback main category is missing from canonical categories");
}
return map;
}
}
export async function ensureCanonicalCategory(
export async function classifyArticleCategory(
db: Database,
category: CanonicalCategory,
article: ArticleCategories,
): Promise<CategoryRow> {
const payload = {
candidates: category.candidates,
description: category.description ?? null,
embeddings: null,
id: category.id,
name: category.name,
slug: category.slug,
weight: category.weight,
} satisfies typeof categories.$inferInsert;
const configured = await getClassificationCategories(db);
const best = classifyCategory(article, configured);
const category = configured.find((item) => item.slug === best.category.slug);
const [created] = await db.insert(categories).values(payload).onConflictDoNothing().returning();
if (created) return created;
const existing = await db.query.categories.findFirst({
where: eq(categories.slug, category.slug),
});
if (!existing) {
throw new Error(`Could not initialize canonical category '${category.slug}'`);
if (!category) {
throw new Error("No category is available for article classification");
}
return existing;
return category;
}
export function classifyCategory(article: ArticleCategories): CategoryScore {
export function classifyCategory(
article: ArticleCategories,
configured: readonly ClassifierCategory[] = Categories,
): CategoryScore {
const fallback =
configured.find((category) => category.slug === DEFAULT_CATEGORY) ??
[...configured].sort((left, right) => left.weight - right.weight)[0] ??
FALLBACK_CATEGORY;
const categoryOrder = new Map(configured.map((category, index) => [category.slug, index]));
const candidateMap = buildCandidateMap(configured);
const rawCategories = article.categories ?? [];
const normalizedCategories = Array.from(
new Set(
@@ -164,7 +122,7 @@ export function classifyCategory(article: ArticleCategories): CategoryScore {
const scores = new Map<string, CategoryScore>();
for (const normalized of normalizedCategories) {
const categories = CANDIDATE_MAP.get(normalized);
const categories = candidateMap.get(normalized);
if (!categories) continue;
for (const category of categories) {
@@ -183,7 +141,7 @@ export function classifyCategory(article: ArticleCategories): CategoryScore {
}
if (scores.size === 0) {
return { category: FALLBACK_CATEGORY, matches: 0, score: 0 };
return { category: fallback, matches: 0, score: 0 };
}
const [first, ...rest] = Array.from(scores.values());
@@ -201,19 +159,51 @@ export function classifyCategory(article: ArticleCategories): CategoryScore {
return candidate.matches > winner.matches ? candidate : winner;
}
const winnerOrder = CATEGORY_MAP.get(winner.category.slug) ?? Number.MAX_SAFE_INTEGER;
const candidateOrder = CATEGORY_MAP.get(candidate.category.slug) ?? Number.MAX_SAFE_INTEGER;
const winnerOrder = categoryOrder.get(winner.category.slug) ?? Number.MAX_SAFE_INTEGER;
const candidateOrder = categoryOrder.get(candidate.category.slug) ?? Number.MAX_SAFE_INTEGER;
return candidateOrder < winnerOrder ? candidate : winner;
}, first ?? { category: FALLBACK_CATEGORY, matches: 0, score: 0 });
}, first ?? { category: fallback, matches: 0, score: 0 });
return best;
}
function buildCandidateMap(): Map<string, (typeof Categories)[number][]> {
const map = new Map<string, (typeof Categories)[number][]>();
async function getClassificationCategories(db: Database): Promise<CategoryRow[]> {
let configured = await db.query.categories.findMany({
orderBy: [desc(categories.weight), asc(categories.name)],
});
for (const category of Categories) {
if (configured.length > 0) {
return configured;
}
const payload = Categories.map(
(category) =>
({
candidates: category.candidates,
description: category.description ?? null,
embeddings: null,
id: category.id,
name: category.name,
slug: category.slug,
weight: category.weight,
}) satisfies typeof categories.$inferInsert,
);
await db.insert(categories).values(payload).onConflictDoNothing();
configured = await db.query.categories.findMany({
orderBy: [desc(categories.weight), asc(categories.name)],
});
return configured;
}
function buildCandidateMap(
configured: readonly ClassifierCategory[],
): Map<string, ClassifierCategory[]> {
const map = new Map<string, ClassifierCategory[]>();
for (const category of configured) {
for (const candidate of category.candidates) {
const normalized = normalizeCategory(candidate);
if (!normalized) continue;
+27 -2
View File
@@ -3,7 +3,7 @@ import z from "zod";
import { idSchema } from "./shared";
export const categorySchema = z.object({
candidates: z.array(z.string()),
candidates: z.array(z.string().trim().min(1).max(255)),
createdAt: z.coerce.date(),
description: z.string().max(512).optional(),
embeddings: z.array(z.number()).optional(),
@@ -11,10 +11,35 @@ export const categorySchema = z.object({
name: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
updatedAt: z.coerce.date().optional(),
weight: z.number().int(),
weight: z.number().int().min(0).max(100),
});
export const createCategorySchema = categorySchema
.pick({
candidates: true,
description: true,
name: true,
slug: true,
weight: true,
})
.extend({
candidates: categorySchema.shape.candidates.min(1),
name: categorySchema.shape.name.trim(),
slug: categorySchema.shape.slug
.trim()
.toLowerCase()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase words separated by hyphens."),
});
export const updateCategorySchema = createCategorySchema.extend({
id: categorySchema.shape.id,
});
export const deleteCategorySchema = categorySchema.pick({ id: true });
export type Category = z.infer<typeof categorySchema>;
export type CreateCategory = z.infer<typeof createCategorySchema>;
export type UpdateCategory = z.infer<typeof updateCategorySchema>;
export const Categories: Category[] = [
{
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import {
createUserFormSchema,
getBanDurationSeconds,
setUserPasswordFormSchema,
} from "../../../../../apps/dashboard/src/features/identity/users/user-form-schema";
describe("user management forms", () => {
test("normalizes account details and accepts a supported role", () => {
const result = createUserFormSchema.parse({
confirmPassword: "a secure password",
email: " ADMIN@Example.com ",
name: " Admin User ",
password: "a secure password",
role: "admin",
});
expect(result).toMatchObject({
email: "admin@example.com",
name: "Admin User",
role: "admin",
});
});
test("requires matching passwords", () => {
const result = setUserPasswordFormSchema.safeParse({
confirmPassword: "another password",
password: "a secure password",
});
expect(result.success).toBe(false);
});
test("maps temporary and permanent ban durations", () => {
expect(getBanDurationSeconds("day")).toBe(86_400);
expect(getBanDurationSeconds("week")).toBe(604_800);
expect(getBanDurationSeconds("month")).toBe(2_592_000);
expect(getBanDurationSeconds("permanent")).toBeUndefined();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import { classifyCategory } from "../../../packages/db/src/services/category-classifier";
const configured = [
{
candidates: ["actualité"],
id: "019c0000-0000-7000-8000-000000000001",
name: "News",
slug: "news",
weight: 2,
},
{
candidates: ["politique", "actualité"],
id: "019c0000-0000-7000-8000-000000000002",
name: "Politics",
slug: "politics",
weight: 10,
},
];
describe("managed category classifier", () => {
test("uses edited candidates and normalizes accents", () => {
const result = classifyCategory(
{
categories: ["Actualite", "POLITIQUE"],
id: "019c0000-0000-7000-8000-000000000010",
},
configured,
);
expect(result.category.slug).toBe("politics");
expect(result.matches).toBe(2);
});
test("falls back to the lowest-weight managed category", () => {
const result = classifyCategory(
{
categories: ["unknown-label"],
id: "019c0000-0000-7000-8000-000000000011",
},
configured,
);
expect(result.category.slug).toBe("news");
expect(result.matches).toBe(0);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import {
createCategorySchema,
updateCategorySchema,
} from "../../../packages/domain/src/models/categories";
describe("category management contracts", () => {
test("normalizes a managed category payload", () => {
const category = createCategorySchema.parse({
candidates: [" politique ", "élections"],
description: "Government news",
name: " Politics ",
slug: "politics-government",
weight: 10,
});
expect(category.name).toBe("Politics");
expect(category.candidates).toEqual(["politique", "élections"]);
});
test("rejects invalid slugs and empty candidate lists", () => {
expect(
createCategorySchema.safeParse({
candidates: [],
name: "Politics",
slug: "Politics & Government",
weight: 10,
}).success,
).toBeFalse();
});
test("requires an id when updating a category", () => {
expect(
updateCategorySchema.safeParse({
candidates: ["politique"],
name: "Politics",
slug: "politics",
weight: 10,
}).success,
).toBeFalse();
});
});