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
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:
@@ -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,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 }) => {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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}'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
-1
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -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 · L’actualité 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 l’article…" />
|
||||
</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 l’article"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={handleShare}
|
||||
/>
|
||||
<Stack.Toolbar.Menu accessibilityLabel="Actions de l’article" 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 l’actualité…" />
|
||||
) : articleFeed.isError && articleItems.length === 0 ? (
|
||||
<ErrorState onRetry={() => void handleRefresh()} />
|
||||
) : (
|
||||
<YStack gap="$5">
|
||||
<YStack gap="$2">
|
||||
<SectionHeader
|
||||
onAction={() => router.push("/(app)/(tabs)/articles/all")}
|
||||
title="À la une"
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ gap: sectionGap }}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{featuredArticles.map((article) => (
|
||||
<FeaturedArticleCard article={article} key={article.id} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<SectionHeader
|
||||
onAction={() => router.push("/(app)/(tabs)/articles/all")}
|
||||
title="Dernières actualités"
|
||||
/>
|
||||
{latestArticles.map((article) => (
|
||||
<ArticleCard article={article} key={article.id} />
|
||||
))}
|
||||
<ArticleListFooter
|
||||
hasError={articleFeed.isFetchNextPageError}
|
||||
isLoading={articleFeed.isFetchingNextPage}
|
||||
onRetry={articleFeed.loadNextPage}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
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 l’article ?", "Il ne figurera plus dans cette collection.", [
|
||||
{ style: "cancel", text: "Annuler" },
|
||||
{
|
||||
onPress: () => removeArticle.mutate({ articleId, bookmarkId: id }),
|
||||
style: "destructive",
|
||||
text: "Retirer",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (bookmarks.isPending || articles.isPending) {
|
||||
return (
|
||||
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
|
||||
<LoadingState label="Chargement du signet…" />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
if (bookmarks.isError || (articles.isError && articles.articles.length === 0) || !bookmark) {
|
||||
return (
|
||||
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
|
||||
<ErrorState onRetry={() => void Promise.all([bookmarks.refetch(), articles.refetch()])} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
|
||||
<Stack.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 l’icône de signet pour l’ajouter 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 +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 d’envoyer le lien de réinitialisation.",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
form.setError("root", {
|
||||
message: "Si cette adresse est associée à un compte, un lien vient de vous être envoyé.",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
|
||||
const isSuccess = form.formState.errors.root?.type === "success";
|
||||
|
||||
return (
|
||||
<KeyboardScreen hasNativeHeader>
|
||||
<YStack
|
||||
flex={1}
|
||||
gap="$4"
|
||||
justifyContent="space-between"
|
||||
paddingBottom="$6"
|
||||
paddingHorizontal="$4"
|
||||
paddingTop="$5"
|
||||
>
|
||||
<YStack flex={1} gap="$4">
|
||||
<Text>
|
||||
Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation de mot de
|
||||
passe.
|
||||
</Text>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
error={fieldState.error?.message}
|
||||
keyboardType="email-address"
|
||||
label="Adresse e-mail"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
placeholder="vous@exemple.com"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<Text color={isSuccess ? "$primary" : "$danger"} variant="caption">
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Link asChild href="/(auth)/sign-in">
|
||||
<Text>Vous avez déjà un compte ? Se connecter</Text>
|
||||
</Link>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleRequest)}
|
||||
>
|
||||
Réinitialiser le mot de passe
|
||||
</Button>
|
||||
</YStack>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
export { ForgotPasswordScreen as default } from "#mobile/features/identity/auth/screens/forgot-password-screen";
|
||||
|
||||
@@ -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 d’actualités intelligente.</Text>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
error={fieldState.error?.message}
|
||||
keyboardType="email-address"
|
||||
label="Adresse e-mail"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
placeholder="vous@exemple.com"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState }) => (
|
||||
<PasswordInput
|
||||
error={fieldState.error?.message}
|
||||
label="Mot de passe"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
placeholder="Votre mot de passe"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Link asChild href="/(auth)/forgot-password">
|
||||
<Text color="$primary">Mot de passe oublié ?</Text>
|
||||
</Link>
|
||||
|
||||
{error ? (
|
||||
<Text color="$danger" variant="caption">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<Text variant="caption">
|
||||
En continuant, vous acceptez les conditions d’utilisation de Basango et reconnaissez
|
||||
avoir lu notre politique de confidentialité.
|
||||
</Text>
|
||||
|
||||
<Link asChild href="/(auth)/sign-up">
|
||||
<Text>Vous n’avez pas de compte ? Créer un compte</Text>
|
||||
</Link>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleSignIn)}
|
||||
>
|
||||
Se connecter
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
export { SignInScreen as default } from "#mobile/features/identity/auth/screens/sign-in-screen";
|
||||
|
||||
@@ -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 d’utilisation de Basango et reconnaissez
|
||||
avoir lu notre politique de confidentialité.
|
||||
</Text>
|
||||
|
||||
<Link asChild href="/(auth)/sign-in">
|
||||
<Text>Vous avez un compte ? Connectez-vous</Text>
|
||||
</Link>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleSignUp)}
|
||||
>
|
||||
Créer un compte
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
export { SignUpScreen as default } from "#mobile/features/identity/auth/screens/sign-up-screen";
|
||||
|
||||
@@ -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 d’actualités intelligente qui vous aide à rester informé sur
|
||||
l’actualité congolaise et internationale.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Button onPress={() => router.push("/(auth)/sign-in")}>Se connecter</Button>
|
||||
<Text onPress={() => router.push("/(auth)/sign-up")} textAlign="center">
|
||||
Ouvrir un compte
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<Text textAlign="center" variant="caption">
|
||||
En continuant, vous acceptez les conditions d’utilisation de Basango et reconnaissez avoir
|
||||
lu notre politique de confidentialité.
|
||||
</Text>
|
||||
</YStack>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
export { WelcomeScreen as default } from "#mobile/features/identity/auth/screens/welcome-screen";
|
||||
|
||||
@@ -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 n’existe pas
|
||||
</Text>
|
||||
<Text textAlign="center" variant="caption">
|
||||
Revenez aux actualités pour continuer.
|
||||
</Text>
|
||||
<Button onPress={() => router.replace("/")}>Retour à l’accueil</Button>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
export { NotFoundScreen as default } from "#mobile/application/navigation/screens/not-found-screen";
|
||||
|
||||
@@ -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 +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 d’au moins huit caractères.</Text>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState }) => (
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
error={fieldState.error?.message}
|
||||
label="Nouveau mot de passe"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field, fieldState }) => (
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
error={fieldState.error?.message}
|
||||
label="Confirmer le mot de passe"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<Text color="$danger" variant="caption">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid || !token}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleReset)}
|
||||
>
|
||||
Enregistrer le mot de passe
|
||||
</Button>
|
||||
</YStack>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function firstParam(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
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 n’existe pas
|
||||
</Text>
|
||||
<Text textAlign="center" variant="caption">
|
||||
Revenez aux actualités pour continuer.
|
||||
</Text>
|
||||
<Button onPress={() => router.replace("/")}>Retour à l’accueil</Button>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 l’article…" />
|
||||
</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 l’article"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={handleShare}
|
||||
/>
|
||||
<Stack.Toolbar.Menu accessibilityLabel="Actions de l’article" 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 l’actualité…" />
|
||||
) : 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 d’ajouter 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 d’abord un signet depuis l’onglet 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 d’ajouter 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 l’article ?", "Il ne figurera plus dans cette collection.", [
|
||||
{ style: "cancel", text: "Annuler" },
|
||||
{
|
||||
onPress: () => removeArticle.mutate({ articleId, bookmarkId: id }),
|
||||
style: "destructive",
|
||||
text: "Retirer",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (bookmarks.isPending || articles.isPending) {
|
||||
return (
|
||||
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
|
||||
<LoadingState label="Chargement du signet…" />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
if (bookmarks.isError || (articles.isError && articles.articles.length === 0) || !bookmark) {
|
||||
return (
|
||||
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
|
||||
<ErrorState onRetry={() => void Promise.all([bookmarks.refetch(), articles.refetch()])} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen backgroundColor="$groupedBackground" hasNativeHeader>
|
||||
<Stack.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 l’icône de signet pour l’ajouter 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 · L’actualité 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 d’envoyer le lien de réinitialisation.",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
form.setError("root", {
|
||||
message: "Si cette adresse est associée à un compte, un lien vient de vous être envoyé.",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
|
||||
const isSuccess = form.formState.errors.root?.type === "success";
|
||||
|
||||
return (
|
||||
<KeyboardScreen hasNativeHeader>
|
||||
<YStack
|
||||
flex={1}
|
||||
gap="$4"
|
||||
justifyContent="space-between"
|
||||
paddingBottom="$6"
|
||||
paddingHorizontal="$4"
|
||||
paddingTop="$5"
|
||||
>
|
||||
<YStack flex={1} gap="$4">
|
||||
<Text>
|
||||
Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation de mot de
|
||||
passe.
|
||||
</Text>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
error={fieldState.error?.message}
|
||||
keyboardType="email-address"
|
||||
label="Adresse e-mail"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
placeholder="vous@exemple.com"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<Text color={isSuccess ? "$primary" : "$danger"} variant="caption">
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Link asChild href="/(auth)/sign-in">
|
||||
<Text>Vous avez déjà un compte ? Se connecter</Text>
|
||||
</Link>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleRequest)}
|
||||
>
|
||||
Réinitialiser le mot de passe
|
||||
</Button>
|
||||
</YStack>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 d’au moins huit caractères.</Text>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState }) => (
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
error={fieldState.error?.message}
|
||||
label="Nouveau mot de passe"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field, fieldState }) => (
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
error={fieldState.error?.message}
|
||||
label="Confirmer le mot de passe"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<Text color="$danger" variant="caption">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid || !token}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleReset)}
|
||||
>
|
||||
Enregistrer le mot de passe
|
||||
</Button>
|
||||
</YStack>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function firstParam(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -0,0 +1,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 d’actualités intelligente.</Text>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
error={fieldState.error?.message}
|
||||
keyboardType="email-address"
|
||||
label="Adresse e-mail"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
placeholder="vous@exemple.com"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState }) => (
|
||||
<PasswordInput
|
||||
error={fieldState.error?.message}
|
||||
label="Mot de passe"
|
||||
onBlur={field.onBlur}
|
||||
onChangeText={field.onChange}
|
||||
placeholder="Votre mot de passe"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Link asChild href="/(auth)/forgot-password">
|
||||
<Text color="$primary">Mot de passe oublié ?</Text>
|
||||
</Link>
|
||||
|
||||
{error ? (
|
||||
<Text color="$danger" variant="caption">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<Text variant="caption">
|
||||
En continuant, vous acceptez les conditions d’utilisation de Basango et reconnaissez
|
||||
avoir lu notre politique de confidentialité.
|
||||
</Text>
|
||||
|
||||
<Link asChild href="/(auth)/sign-up">
|
||||
<Text>Vous n’avez pas de compte ? Créer un compte</Text>
|
||||
</Link>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleSignIn)}
|
||||
>
|
||||
Se connecter
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 d’utilisation de Basango et reconnaissez
|
||||
avoir lu notre politique de confidentialité.
|
||||
</Text>
|
||||
|
||||
<Link asChild href="/(auth)/sign-in">
|
||||
<Text>Vous avez un compte ? Connectez-vous</Text>
|
||||
</Link>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
onPress={form.handleSubmit(handleSignUp)}
|
||||
>
|
||||
Créer un compte
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</KeyboardScreen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { YStack } from "tamagui";
|
||||
|
||||
import { Button } from "#mobile/ui/components/button";
|
||||
import { LogoMark } from "#mobile/ui/components/logo-mark";
|
||||
import { Screen } from "#mobile/ui/components/screen";
|
||||
import { Text } from "#mobile/ui/components/text";
|
||||
|
||||
export 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 d’actualités intelligente qui vous aide à rester informé sur
|
||||
l’actualité congolaise et internationale.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Button onPress={() => router.push("/(auth)/sign-in")}>Se connecter</Button>
|
||||
<Text onPress={() => router.push("/(auth)/sign-up")} textAlign="center">
|
||||
Ouvrir un compte
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<Text textAlign="center" variant="caption">
|
||||
En continuant, vous acceptez les conditions d’utilisation de Basango et reconnaissez avoir
|
||||
lu notre politique de confidentialité.
|
||||
</Text>
|
||||
</YStack>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
+10
-6
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
addReaderArticleToBookmark,
|
||||
createReaderBookmark,
|
||||
deleteReaderBookmark,
|
||||
getReaderArticleBookmarkMemberships,
|
||||
getReaderBookmarkArticles,
|
||||
getReaderBookmarks,
|
||||
removeReaderArticleFromBookmark,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user