Compare commits

...

3 commits

Author SHA1 Message Date
5d89a81723 docs: mise à jour documentation (critère notoriété, ports Supabase, commandes) 2026-03-10 01:10:44 +01:00
7f9efc5ee2 feat: refonte page /p avec vue SQL pré-agrégée, recherche et index A-Z
Remplace le findAll() + getStats() par personnalité (843 requêtes) par une vue SQL v_public_figure_activity_summary (2-3 requêtes). La page affiche maintenant 4 sections : recherche, top 10 actives, activité récente, et index alphabétique. Renomme aussi subject_activity_summary en v_subject_activity_summary pour uniformiser la convention de nommage des vues.
2026-03-10 01:10:44 +01:00
fcfe77117a feat: rendre wikipedia_url optionnel et ajouter notoriety_sources
Le critère de notoriété accepte désormais soit une page Wikipedia, soit deux sources indépendantes (notoriety_sources). Cela permet d'ajouter des personnalités publiques notables qui n'ont pas de page Wikipedia.
2026-03-10 01:10:41 +01:00
23 changed files with 703 additions and 218 deletions

View file

@ -8,7 +8,7 @@
- **TypeScript** pour le typage fort
- **Next.js** pour le fullstack
- **Supabase** pour la persistence (PostgreSQL + Auth)
- **Tailwind CSS** + CSS Modules pour le styling
- **CSS Modules** pour le styling
### Organisation des dossiers
@ -25,18 +25,15 @@
### Prérequis
- Node.js 18+
- Node.js 22+
- Docker (pour Supabase local)
- npm ou pnpm
- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started)
### Installation
```bash
# Installer les dépendances
npm install
# Installer la CLI Supabase globalement
npm install -g supabase
```
### Commandes Supabase essentielles
@ -61,8 +58,8 @@ supabase start
# Créer une nouvelle migration
supabase migration new nom_de_la_migration
# Appliquer les migrations
supabase db reset
# Appliquer les migrations (incrémental, préserve les données)
supabase migration up
# Voir le statut des migrations
supabase migration list
@ -71,6 +68,8 @@ supabase migration list
supabase migration repair --status applied
```
> **Attention** : `supabase db reset` détruit toutes les données locales et recrée la base depuis zéro. Préférer `supabase migration up` pour le développement courant.
#### Base de données
```bash
@ -94,7 +93,7 @@ supabase gen types typescript --local > types/database.types.ts
supabase status
# Accéder au Studio (interface admin)
# Ouvrir http://127.0.0.1:54323
# Ouvrir http://127.0.0.1:64323
# Voir les logs en temps réel
supabase logs --follow
@ -159,10 +158,12 @@ Référence : [Understanding API keys | Supabase Docs](https://supabase.com/docs
### URLs locales importantes
- **API**: http://127.0.0.1:54321
- **DB**: postgresql://postgres:postgres@127.0.0.1:54322/postgres
- **Studio**: http://127.0.0.1:54323
- **Inbucket** (emails): http://127.0.0.1:54324
Les ports sont configurés dans `supabase/config.toml` :
- **API**: http://127.0.0.1:64321
- **DB**: postgresql://postgres:postgres@127.0.0.1:64322/postgres
- **Studio**: http://127.0.0.1:64323
- **Inbucket** (emails): http://127.0.0.1:64324
## Workflow de développement
@ -183,14 +184,17 @@ Référence : [Understanding API keys | Supabase Docs](https://supabase.com/docs
### 3. Avant de commiter
```bash
# Lancer les tests
npm test
# Vérification complète (lint + format + typecheck + build)
npm run check
```
# Vérifier le linting
npm run lint
Commandes individuelles disponibles :
# Formater le code
npm run format
```bash
npm test # Tests (Vitest)
npm run lint # Linting (ESLint)
npm run format # Formatage (Prettier)
npm run typecheck # Vérification des types
```
## Règles métier importantes
@ -202,11 +206,18 @@ npm run format
- Éditer un sujet: minimum 100 points requis
- Modération: réservée aux utilisateurs de confiance
### Personnalités publiques
- **Critère de notoriété** : une personnalité doit avoir fait l'objet d'au moins deux publications dans des sources indépendantes et fiables (presse, institution, rapport officiel…), inspiré des [critères d'admissibilité Wikipedia](https://fr.wikipedia.org/wiki/Wikip%C3%A9dia:Notori%C3%A9t%C3%A9_des_personnes)
- La page Wikipedia n'est pas obligatoire mais reste un enrichissement recommandé
- Sans page Wikipedia, le contributeur doit fournir au moins 2 URLs de sources indépendantes attestant de la notoriété
### Prises de position
- Une personnalité peut changer de position sur un sujet au fil du temps ; chaque prise de position est datée
- Toute prise de position doit avoir au moins une preuve
- Les preuves doivent être sourcées et datées
- Une preuve (evidence) doit pointer vers une source publiquement accessible et vérifiable : source primaire (tweet officiel, communiqué, discours filmé) ou source tierce (article de presse, interview, audition parlementaire)
## Ressources

View file

@ -47,7 +47,7 @@ Le projet a été initié en 2014 et a connu plusieurs itérations technologique
### 5. Next.js Standalone (actuel)
- **Stack** : Next.js 15 + TypeScript + Supabase + Effect TS
- **Stack** : Next.js 16 + TypeScript + Supabase + Effect TS
- **État** : En cours de développement
- **Localisation** : racine du projet
- **Objectif** : Consolidation et première version exploitable
@ -64,38 +64,44 @@ Subject (Sujet)
├── Evidence (Preuves/Sources)
└── Arguments
Users (Contributeurs)
Contributors (Contributeurs)
```
### Entités principales
- **Subject** : Les sujets de débat (ex: "Immigration", "Écologie", "Retraites")
- **Position** : Les différentes positions possibles sur un sujet
- **PublicFigure** : Les personnalités publiques (politiques, intellectuels, etc.) - **Critère de notoriété : toute personnalité doit avoir une page Wikipedia valide**
- **PublicFigure** : Les personnalités publiques (politiques, intellectuels, dirigeants d'institutions, porte-paroles d'organisations, etc.) - **Critère de notoriété : la personnalité doit avoir fait l'objet d'au moins deux publications dans des sources indépendantes et fiables** (inspiré des [critères d'admissibilité Wikipedia](https://fr.wikipedia.org/wiki/Wikip%C3%A9dia:Notori%C3%A9t%C3%A9_des_personnes)). La page Wikipedia n'est pas obligatoire.
- **Statement** : Les prises de position concrètes d'une personnalité sur une position
- **Evidence** : Les preuves et sources (citations, articles, vidéos, discours)
- **Argument** : Les arguments développés pour défendre une position
- **User** : Les utilisateurs contributeurs de la plateforme
- **Contributor** : Les utilisateurs contributeurs de la plateforme
## Architecture technique actuelle
### Stack
- **Frontend/Backend** : Next.js 15 (App Router)
- **Frontend/Backend** : Next.js 16 (App Router)
- **Base de données** : PostgreSQL via Supabase
- **Authentification** : Supabase Auth
- **Domain** : Effect TS + Effect Schema
- **Styling** : CSS Modules
- **Tests** : Vitest
- **Monitoring** : Sentry + Plausible
### Structure
```
├── app/ # Next.js App Router
├── domain/ # Logique métier (entités, services, règles)
├── infra/ # Infrastructure (Supabase, APIs)
├── app/ # Next.js App Router (pages et Server Actions)
├── domain/ # Logique métier (entités, services, règles, use cases)
├── infra/ # Infrastructure (Supabase, Wikipedia API)
├── components/ # Composants React réutilisables
├── hooks/ # React hooks
├── styles/ # CSS Modules et design system
├── supabase/ # Migrations et seeds
└── types/ # Types générés
├── types/ # Types générés (database.types.ts)
├── docs/ # Documentation et maquettes de référence
└── public/ # Assets statiques (polices, images)
```
### Projets legacy (référence)
@ -107,47 +113,87 @@ Users (Contributeurs)
### Prérequis
- Node.js 20+
- Node.js 22+
- Docker (pour Supabase local)
- Nix + direnv (recommandé)
- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started)
### Installation
```bash
npm install
supabase start
supabase db reset # Applique migrations + seeds
supabase migration up # Applique les migrations
```
> **Attention** : `supabase db reset` détruit toutes les données locales. Préférer `supabase migration up` pour appliquer les migrations de manière incrémentale.
### Variables d'environnement
Créer un fichier `.env.local` :
Copier `.env.example` vers `.env.local` et renseigner les valeurs :
```env
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<clé publishable locale>
```bash
cp .env.example .env.local
```
### Lancement du développement
Les variables essentielles pour le développement local :
```env
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:64321
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<clé affichée par supabase start>
SUPABASE_SECRET_KEY=<clé affichée par supabase start>
SIGNUP_SECRET_TOKEN=<token pour l'inscription par invitation>
```
Voir `.env.example` pour la liste complète des variables.
### Lancement
```bash
npm run dev
```
### Commandes utiles
```bash
npm run dev # Serveur de développement
npm test # Tests (Vitest)
npm run lint # Linting (ESLint)
npm run format # Formatage (Prettier)
npm run typecheck # Vérification des types
npm run check # Lint + format + typecheck + build (à lancer avant chaque commit)
```
### Supabase local
```bash
supabase start # Démarrer l'environnement local
supabase stop # Arrêter l'environnement
supabase migration up # Appliquer les migrations
# Générer les types TypeScript après chaque migration
supabase gen types typescript --local > types/database.types.ts
```
Les URLs locales sont configurées sur le port **64321** (voir `supabase/config.toml`) :
- **API** : http://127.0.0.1:64321
- **Studio** : http://127.0.0.1:64323
- **Inbucket** (emails) : http://127.0.0.1:64324
## Feuille de route
### Phase 1 : MVP
- [ ] Consolidation du modèle de données
- [ ] Interface de base pour consulter sujets et positions
- [ ] Authentification simple
- [ ] CRUD basique pour les entités principales
- [x] Consolidation du modèle de données
- [x] Interface de consultation des sujets et positions
- [x] Authentification Supabase (inscription par invitation)
- [x] CRUD sujets, personnalités, positions, prises de position
- [x] Système de réputation des contributeurs
### Phase 2 : Fonctionnalités collaboratives
- [ ] Système de contribution et modération
- [ ] Système de modération
- [ ] Historique des modifications
- [ ] Système de réputation des contributeurs
- [ ] Interface d'administration
### Phase 3 : Fonctionnalités avancées

View file

@ -7,6 +7,7 @@ import { createPublicFigureRepository } from '../../infra/database/public-figure
export interface PublicFigureSearchResult {
id: string
name: string
slug: string
}
export async function searchPublicFigures(query: string): Promise<PublicFigureSearchResult[]> {
@ -17,5 +18,5 @@ export async function searchPublicFigures(query: string): Promise<PublicFigureSe
const figures = await Effect.runPromise(repo.searchByName(query, 10))
return figures.map((f) => ({ id: f.id, name: f.name }))
return figures.map((f) => ({ id: f.id, name: f.name, slug: f.slug }))
}

View file

@ -96,12 +96,14 @@ export default function GuidePage() {
</p>
<p>
Il est nécessaire pour le contributeur de se demander si le contenu est suffisamment
important, fiable et pertinent pour être référencé ou recensé sur Débats.co. Basé sur une
logique collaborative, le site comporte plusieurs mécanismes d&apos;autorégulation,
permettant d&apos;éviter la publication de contenu considérés comme fallacieux ou
hors-sujet. Chaque entrée, notamment lors du référencement d&apos;une nouvelle
personnalité ou d&apos;un nouveau sujet, sont soumis à l&apos;approbation des utilisateurs
expérimentés de la plateforme.
important, fiable et pertinent pour être référencé ou recensé sur Débats.co. Pour les
personnalités, le critère de notoriété est inspiré des critères d&apos;admissibilité de
Wikipedia : une personnalité doit avoir fait l&apos;objet d&apos;au moins deux
publications dans des sources indépendantes et fiables (article de presse, page
institutionnelle, rapport officiel). Avoir une page Wikipedia n&apos;est pas obligatoire,
mais reste un enrichissement recommandé. Ce critère permet d&apos;inclure des
personnalités locales ou sectorielles (dirigeant·es d&apos;institution, porte-paroles
d&apos;ONG, élu·es locaux) dès lors que leur notoriété est vérifiable.
</p>
<p>
<b>Neutralité et cohérence du point de vue</b>
@ -114,11 +116,7 @@ export default function GuidePage() {
<p>
Afin de s&apos;en rapprocher, il est nécessaire pour chaque utilisateur de se demander
comment exprimer de façon objective l&apos;intitulé des sujets ou les positions des
personnalités. Dans un premier temps, et pour simplifier cette démarche
&quot;objective&quot;, les sujets ainsi que les pages de personnalités peuvent être
référencées directement depuis la base de données de Wikipedia. Il suffira donc, après la
composition des premières lettres de l&apos;intitulé de la personnalité ou du sujet, de
sélectionner l&apos;élément correspondant.
personnalités.
</p>
<p>
Lors de la recension d&apos;une prise d&apos;une position ensuite, il est important

View file

@ -1,7 +1,7 @@
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Effect } from 'effect'
import { Effect, Option } from 'effect'
import { createSSRSupabaseClient } from '../../../infra/supabase/ssr'
import { createPublicFigureRepository } from '../../../infra/database/public-figure-repository-supabase'
import { createStatementRepository } from '../../../infra/database/statement-repository-supabase'
@ -104,9 +104,9 @@ export default async function PersonalityDetailPage({ params }: PageProps) {
<div className={styles.headerInfo}>
<h1 className={styles.name}>{figure.name}</h1>
<p className={styles.presentation}>{figure.presentation}</p>
{figure.wikipediaUrl && (
{Option.isSome(figure.wikipediaUrl) && (
<a
href={figure.wikipediaUrl}
href={figure.wikipediaUrl.value}
target="_blank"
rel="noopener noreferrer"
className={styles.wikiLink}

View file

@ -3,10 +3,12 @@ import Link from 'next/link'
import { Effect } from 'effect'
import { createSSRSupabaseClient } from '../../infra/supabase/ssr'
import { createPublicFigureRepository } from '../../infra/database/public-figure-repository-supabase'
import { PublicFigureActivitySummary } from '../../domain/value-objects/public-figure-activity-summary'
import { getAuthenticatedContributor } from '../actions/get-authenticated-contributor'
import { canPerform } from '../../domain/reputation/permissions'
import Button from '../../components/ui/Button'
import FigureAvatar from '../../components/figures/FigureAvatar'
import PersonalitySearch from '../../components/figures/PersonalitySearch'
import ContentWithSidebar from '../../components/layout/ContentWithSidebar'
import ErrorDisplay from '../../components/layout/ErrorDisplay'
import styles from './personalities.module.css'
@ -17,26 +19,67 @@ export const metadata: Metadata = {
'Les personnalités publiques référencées sur Débats.co et leurs prises de position sur les sujets de société.',
}
export default async function PersonalitiesPage() {
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
function formatRelativeDate(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return "Aujourd'hui"
if (diffDays === 1) return 'Hier'
if (diffDays < 30) return `Il y a ${diffDays} jours`
const diffMonths = Math.floor(diffDays / 30)
if (diffMonths < 12) return `Il y a ${diffMonths} mois`
return date.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })
}
function FigureCard({ figure, stat }: { figure: PublicFigureActivitySummary; stat: string }) {
return (
<Link href={`/p/${figure.slug}`} className={styles.figureCard}>
<FigureAvatar slug={figure.slug} name={figure.name} size={80} />
<h3 className={styles.figureName}>{figure.name}</h3>
<span className={styles.figureStat}>{stat}</span>
</Link>
)
}
function FigureRow({ figure }: { figure: PublicFigureActivitySummary }) {
return (
<div className={styles.figureRow}>
<FigureAvatar slug={figure.slug} name={figure.name} size={48} />
<div className={styles.figureRowInfo}>
<Link href={`/p/${figure.slug}`} className={styles.figureRowName}>
{figure.name}
</Link>
<span className={styles.figureRowStat}>
{figure.subjectsCount} sujet{figure.subjectsCount !== 1 ? 's' : ''}
</span>
</div>
</div>
)
}
interface PageProps {
searchParams: Promise<{ lettre?: string }>
}
export default async function PersonalitiesPage({ searchParams }: PageProps) {
try {
const { lettre } = await searchParams
const supabase = await createSSRSupabaseClient()
const publicFigureRepo = createPublicFigureRepository(supabase)
const repo = createPublicFigureRepository(supabase)
const contributor = await getAuthenticatedContributor()
const canAddPersonality = contributor
? canPerform(contributor.reputation, 'add_personality')
: false
const publicFigures = await Effect.runPromise(publicFigureRepo.findAll())
const publicFiguresWithStats = await Promise.all(
publicFigures.map(async (figure) => {
const stats = await Effect.runPromise(publicFigureRepo.getStats(figure.id))
return { figure, stats }
}),
)
publicFiguresWithStats.sort((a, b) => b.stats.subjectsCount - a.stats.subjectsCount)
const [mostActive, recentlyActive, letterFigures] = await Promise.all([
Effect.runPromise(repo.findSummariesByActivity(10, 'subjects_count')),
Effect.runPromise(repo.findSummariesByActivity(10, 'latest_statement_at')),
lettre ? Effect.runPromise(repo.findByLetter(lettre)) : Promise.resolve(null),
])
return (
<ContentWithSidebar topMargin>
@ -49,41 +92,62 @@ export default async function PersonalitiesPage() {
)}
</div>
<div className={styles.personalitiesIndex}>
{publicFiguresWithStats.length === 0 ? (
<p>Aucune personnalité pour le moment.</p>
) : (
publicFiguresWithStats.map(({ figure, stats }) => (
<div key={figure.id} className={styles.personalityItem}>
<div className={styles.personalityIdentity}>
<div className={styles.personalityAvatar}>
<FigureAvatar slug={figure.slug} name={figure.name} />
</div>
<h3 className={styles.personalityName}>
<Link href={`/p/${figure.slug}`}>{figure.name}</Link>
</h3>
<div className={styles.counters}>
<span className={styles.countItem}>
{stats.subjectsCount} sujet
{stats.subjectsCount !== 1 ? 's' : ''} actif
{stats.subjectsCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<PersonalitySearch />
<div className={styles.personalityPresentation}>
<p className={styles.presentationText}>{figure.presentation}</p>
</div>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Les plus actives</h2>
<div className={styles.figureGrid}>
{mostActive.map((figure) => (
<FigureCard
key={figure.id}
figure={figure}
stat={`${figure.subjectsCount} sujet${figure.subjectsCount !== 1 ? 's' : ''}`}
/>
))}
</div>
</section>
<div className={styles.seeMore}>
<Link href={`/p/${figure.slug}`} className={styles.seeMoreLink}>
Voir les sujets actifs
</Link>
</div>
</div>
))
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Activité récente</h2>
<div className={styles.figureGrid}>
{recentlyActive.map((figure) => (
<FigureCard
key={figure.id}
figure={figure}
stat={
figure.latestStatementAt
? formatRelativeDate(figure.latestStatementAt)
: 'Aucune activité'
}
/>
))}
</div>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Index A-Z</h2>
<div className={styles.alphabetBar}>
{ALPHABET.map((l) => (
<Link
key={l}
href={`/p?lettre=${l}`}
className={`${styles.letterLink} ${lettre === l ? styles.letterActive : ''}`}
>
{l}
</Link>
))}
</div>
{letterFigures && (
<div className={styles.letterResults}>
{letterFigures.length === 0 ? (
<p className={styles.noResults}>Aucune personnalité commençant par « {lettre} ».</p>
) : (
letterFigures.map((figure) => <FigureRow key={figure.id} figure={figure} />)
)}
</div>
)}
</div>
</section>
</ContentWithSidebar>
)
} catch (error) {

View file

@ -17,123 +17,174 @@
margin-top: 0;
}
.personalitiesIndex {
/* Sections */
.section {
margin-bottom: 50px;
}
.personalityItem {
.sectionTitle {
font-family: var(--font-gotham-bold);
font-size: 1.1em;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--debats-red);
font-weight: bold;
margin-top: 0;
margin-bottom: 24px;
padding-bottom: 10px;
border-bottom: 2px solid var(--debats-red);
}
/* Figure card grid (top actives) */
.figureGrid {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 30px;
align-items: start;
margin-bottom: 40px;
padding-bottom: 30px;
border-bottom: 1px solid #f0f0f0;
grid-template-columns: repeat(5, 1fr);
gap: 24px;
}
.personalityItem:last-child {
border-bottom: none;
}
.personalityIdentity {
.figureCard {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 150px;
align-items: center;
text-decoration: none;
text-align: center;
padding: 16px 8px;
border-radius: 4px;
transition: background-color 0.15s;
}
.personalityAvatar {
margin-bottom: 12px;
.figureCard:hover {
background-color: #fafafa;
}
.personalityName {
.figureName {
font-family: var(--font-gotham-book);
font-size: 1em;
margin-bottom: 5px;
font-weight: normal;
font-size: 0.9em;
color: var(--text-dark);
margin-top: 0;
margin: 10px 0 4px;
font-weight: normal;
text-transform: uppercase;
}
.personalityName a {
.figureCard:hover .figureName {
color: var(--debats-red);
}
.figureStat {
font-family: var(--font-gotham-book);
font-size: 0.8em;
color: var(--debats-red);
}
/* Figure row list (activité récente, index A-Z) */
.recentList,
.letterResults {
display: flex;
flex-direction: column;
}
.figureRow {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.figureRow:last-child {
border-bottom: none;
}
.figureRowInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.figureRowName {
font-family: var(--font-gotham-book);
font-size: 0.95em;
color: var(--text-dark);
text-decoration: none;
text-transform: uppercase;
}
.personalityName a:hover {
.figureRowName:hover {
color: var(--debats-red);
}
.counters {
}
.countItem {
color: var(--debats-red);
.figureRowStat {
font-family: var(--font-gotham-book);
font-size: 0.85em;
display: block;
font-size: 0.8em;
color: var(--debats-red);
}
.personalityPresentation {
/* Alphabet bar */
.alphabetBar {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 24px;
}
.presentationLabel {
.letterLink {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-family: var(--font-gotham-bold);
font-size: 0.9em;
font-weight: bold;
margin-bottom: 15px;
display: block;
color: var(--text-dark);
text-decoration: none;
border-radius: 4px;
transition:
background-color 0.15s,
color 0.15s;
}
.presentationText {
.letterLink:hover {
background-color: #fafafa;
color: var(--debats-red);
}
.letterActive {
background-color: var(--debats-red);
color: white;
}
.letterActive:hover {
background-color: var(--debats-red);
color: white;
}
.noResults {
font-family: var(--font-gotham-book);
font-size: 0.9em;
line-height: 1.6em;
text-align: justify;
color: var(--text-dark);
margin: 0;
color: #999;
padding: 16px 0;
}
.seeMore {
display: flex;
align-items: flex-start;
padding-top: 30px;
}
.seeMoreLink {
color: var(--debats-red);
text-decoration: none;
font-family: var(--font-gotham-book);
font-size: 0.85em;
white-space: nowrap;
}
.seeMoreLink:hover {
color: #d11635;
}
/* Responsive */
@media (max-width: 768px) {
.pageTitle {
font-size: 1.5em;
}
.personalityItem {
grid-template-columns: 1fr;
gap: 20px;
.figureGrid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.personalityIdentity {
flex-direction: row;
align-items: center;
gap: 15px;
min-width: auto;
}
.personalityAvatar {
margin-bottom: 0;
}
.seeMore {
padding-top: 0;
.letterLink {
width: 32px;
height: 32px;
font-size: 0.85em;
}
}

View file

@ -0,0 +1,58 @@
.searchContainer {
margin-bottom: 40px;
}
.searchInput {
width: 100%;
padding: 12px 16px;
font-family: var(--font-gotham-book);
font-size: 1em;
border: 2px solid #e0e0e0;
border-radius: 4px;
outline: none;
transition: border-color 0.2s;
}
.searchInput:focus {
border-color: var(--debats-red);
}
.searchInput::placeholder {
color: #999;
}
.results {
margin-top: 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: white;
}
.resultItem {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
text-decoration: none;
color: var(--text-dark);
font-family: var(--font-gotham-book);
font-size: 0.95em;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.15s;
}
.resultItem:last-child {
border-bottom: none;
}
.resultItem:hover {
background-color: #fafafa;
color: var(--debats-red);
}
.noResults {
padding: 12px 16px;
color: #999;
font-family: var(--font-gotham-book);
font-size: 0.9em;
}

View file

@ -0,0 +1,62 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import {
searchPublicFigures,
PublicFigureSearchResult,
} from '../../../app/actions/search-public-figures'
import FigureAvatar from '../FigureAvatar'
import styles from './PersonalitySearch.module.css'
export default function PersonalitySearch() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<PublicFigureSearchResult[]>([])
const [hasSearched, setHasSearched] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const search = useCallback(async (q: string) => {
if (q.length < 2) {
setResults([])
setHasSearched(false)
return
}
const data = await searchPublicFigures(q)
setResults(data)
setHasSearched(true)
}, [])
useEffect(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => search(query), 300)
return () => clearTimeout(debounceRef.current)
}, [query, search])
return (
<div className={styles.searchContainer}>
<input
type="search"
className={styles.searchInput}
placeholder="Rechercher une personnalité…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{hasSearched && (
<div className={styles.results}>
{results.length === 0 ? (
<p className={styles.noResults}>Aucun résultat pour « {query} »</p>
) : (
results.map((figure) => (
<Link key={figure.id} href={`/p/${figure.slug}`} className={styles.resultItem}>
<FigureAvatar slug={figure.slug} name={figure.name} size={32} />
{figure.name}
</Link>
))
)}
</div>
)}
</div>
)
}

View file

@ -24,8 +24,9 @@ export const PublicFigure = S.Struct({
name: PublicFigureName,
slug: PublicFigureSlug,
presentation: S.String.pipe(S.minLength(10)),
wikipediaUrl: S.String, // Obligatoire : critère de notoriété
websiteUrl: S.Option(S.String), // Optionnel avec Option
wikipediaUrl: S.Option(S.String),
notorietySources: S.Array(S.String),
websiteUrl: S.Option(S.String),
createdBy: S.String, // Obligatoire : traçabilité
createdAt: S.Date,
updatedAt: S.Date,
@ -48,7 +49,8 @@ export const generateSlug = (name: string): PublicFigureSlug =>
export const createPublicFigure = (params: {
name: string
presentation: string
wikipediaUrl: string
wikipediaUrl?: string
notorietySources?: string[]
websiteUrl?: string
createdBy: string
}): PublicFigure => {
@ -59,7 +61,8 @@ export const createPublicFigure = (params: {
name: PublicFigureName.make(params.name),
slug: generateSlug(params.name),
presentation: params.presentation,
wikipediaUrl: params.wikipediaUrl,
wikipediaUrl: params.wikipediaUrl ? Option.some(params.wikipediaUrl) : Option.none(),
notorietySources: params.notorietySources ?? [],
websiteUrl: params.websiteUrl ? Option.some(params.websiteUrl) : Option.none(),
createdBy: params.createdBy,
createdAt: now,

View file

@ -1,5 +1,6 @@
import { Context, Effect } from 'effect'
import { PublicFigure } from '../entities/public-figure'
import { PublicFigureActivitySummary } from '../value-objects/public-figure-activity-summary'
import { PublicFigureStats } from '../value-objects/public-figure-stats'
export class DatabaseError extends Error {
@ -24,6 +25,13 @@ export interface PublicFigureRepository {
delete(id: string): Effect.Effect<void, DatabaseError>
getStats(publicFigureId: string): Effect.Effect<PublicFigureStats, DatabaseError>
findSummariesByActivity(
limit: number,
orderBy?: 'subjects_count' | 'latest_statement_at',
): Effect.Effect<PublicFigureActivitySummary[], DatabaseError>
findByLetter(letter: string): Effect.Effect<PublicFigureActivitySummary[], DatabaseError>
}
export const PublicFigureRepository =

View file

@ -36,7 +36,8 @@ const fakeFigure = PublicFigure.make({
name: PublicFigureName.make('Jean Dupont'),
slug: PublicFigureSlug.make('jean-dupont'),
presentation: 'Un personnage public suffisamment connu.',
wikipediaUrl: 'https://fr.wikipedia.org/wiki/Jean_Dupont',
wikipediaUrl: Option.some('https://fr.wikipedia.org/wiki/Jean_Dupont'),
notorietySources: [],
websiteUrl: Option.none(),
createdBy: 'abc',
createdAt: new Date(),
@ -96,6 +97,8 @@ const fakePublicFigureRepo = {
positionsCount: 0,
statementsCount: 0,
}),
findSummariesByActivity: () => Effect.succeed([]),
findByLetter: () => Effect.succeed([]),
}
const fakeReputationRepo = {

View file

@ -26,7 +26,8 @@ const fakePublicFigure = PublicFigure.make({
name: PublicFigureName.make('Jean Dupont'),
slug: PublicFigureSlug.make('jean-dupont'),
presentation: 'Un personnage public suffisamment connu.',
wikipediaUrl: 'https://fr.wikipedia.org/wiki/Jean_Dupont',
wikipediaUrl: Option.some('https://fr.wikipedia.org/wiki/Jean_Dupont'),
notorietySources: [],
websiteUrl: Option.none(),
createdBy: 'someone',
createdAt: new Date(),
@ -86,6 +87,8 @@ const fakePublicFigureRepo = {
positionsCount: 0,
statementsCount: 0,
}),
findSummariesByActivity: () => Effect.succeed([]),
findByLetter: () => Effect.succeed([]),
}
const fakeReputationRepo = {

View file

@ -83,6 +83,8 @@ const fakePublicFigureRepo = {
positionsCount: 0,
statementsCount: 0,
}),
findSummariesByActivity: () => Effect.succeed([]),
findByLetter: () => Effect.succeed([]),
}
const fakeReputationRepo = {
@ -381,7 +383,9 @@ describe('createPublicFigureWithStatementUseCase', () => {
expect(Either.isRight(result)).toBe(true)
if (Either.isRight(result)) {
expect(result.right.name).toBe('Jean Dupont')
expect(result.right.wikipediaUrl).toBe('https://fr.wikipedia.org/wiki/Jean_Dupont')
expect(Option.getOrNull(result.right.wikipediaUrl)).toBe(
'https://fr.wikipedia.org/wiki/Jean_Dupont',
)
expect(result.right.createdBy).toBe('abc')
}

View file

@ -25,7 +25,8 @@ const fakePublicFigure = PublicFigure.make({
name: PublicFigureName.make('Jean Dupont'),
slug: PublicFigureSlug.make('jean-dupont'),
presentation: 'Un personnage public suffisamment connu.',
wikipediaUrl: 'https://fr.wikipedia.org/wiki/Jean_Dupont',
wikipediaUrl: Option.some('https://fr.wikipedia.org/wiki/Jean_Dupont'),
notorietySources: [],
websiteUrl: Option.none(),
createdBy: 'someone',
createdAt: new Date(),
@ -63,6 +64,8 @@ const fakePublicFigureRepo = {
delete: () => Effect.succeed(undefined as void),
getStats: () =>
Effect.succeed({ publicFigureId: '', subjectsCount: 0, positionsCount: 0, statementsCount: 0 }),
findSummariesByActivity: () => Effect.succeed([]),
findByLetter: () => Effect.succeed([]),
}
const fakeReputationRepo = {

View file

@ -0,0 +1,12 @@
/**
* Read model for the /p page: public figure with pre-aggregated activity stats.
*/
export interface PublicFigureActivitySummary {
id: string
name: string
slug: string
presentation: string
statementsCount: number
subjectsCount: number
latestStatementAt: Date | null
}

View file

@ -2,6 +2,7 @@ import * as Sentry from '@sentry/nextjs'
import { Effect, Option } from 'effect'
import { SupabaseClient } from '@supabase/supabase-js'
import { PublicFigure } from '../../domain/entities/public-figure'
import { PublicFigureActivitySummary } from '../../domain/value-objects/public-figure-activity-summary'
import {
DatabaseError,
PublicFigureRepository,
@ -28,8 +29,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: figure.name,
slug: figure.slug,
presentation: figure.presentation,
websiteUrl: figure.website_url ? Option.some(figure.website_url) : Option.none(),
wikipediaUrl: figure.wikipedia_url,
websiteUrl: Option.fromNullable(figure.website_url),
wikipediaUrl: Option.fromNullable(figure.wikipedia_url),
notorietySources: figure.notoriety_sources ?? [],
createdAt: new Date(figure.created_at),
updatedAt: new Date(figure.updated_at),
createdBy: figure.created_by,
@ -57,8 +59,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: figure.name,
slug: figure.slug,
presentation: figure.presentation,
websiteUrl: figure.website_url ? Option.some(figure.website_url) : Option.none(),
wikipediaUrl: figure.wikipedia_url,
websiteUrl: Option.fromNullable(figure.website_url),
wikipediaUrl: Option.fromNullable(figure.wikipedia_url),
notorietySources: figure.notoriety_sources ?? [],
createdAt: new Date(figure.created_at),
updatedAt: new Date(figure.updated_at),
createdBy: figure.created_by,
@ -87,8 +90,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: data.name,
slug: data.slug,
presentation: data.presentation,
websiteUrl: data.website_url ? Option.some(data.website_url) : Option.none(),
wikipediaUrl: data.wikipedia_url,
websiteUrl: Option.fromNullable(data.website_url),
wikipediaUrl: Option.fromNullable(data.wikipedia_url),
notorietySources: data.notoriety_sources ?? [],
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
createdBy: data.created_by,
@ -116,8 +120,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: data.name,
slug: data.slug,
presentation: data.presentation,
websiteUrl: data.website_url ? Option.some(data.website_url) : Option.none(),
wikipediaUrl: data.wikipedia_url,
websiteUrl: Option.fromNullable(data.website_url),
wikipediaUrl: Option.fromNullable(data.wikipedia_url),
notorietySources: data.notoriety_sources ?? [],
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
createdBy: data.created_by,
@ -145,8 +150,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: data.name,
slug: data.slug,
presentation: data.presentation,
websiteUrl: data.website_url ? Option.some(data.website_url) : Option.none(),
wikipediaUrl: data.wikipedia_url,
websiteUrl: Option.fromNullable(data.website_url),
wikipediaUrl: Option.fromNullable(data.wikipedia_url),
notorietySources: data.notoriety_sources ?? [],
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
createdBy: data.created_by,
@ -165,10 +171,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: publicFigure.name,
slug: publicFigure.slug,
presentation: publicFigure.presentation,
website_url: Option.isSome(publicFigure.websiteUrl)
? publicFigure.websiteUrl.value
: null,
wikipedia_url: publicFigure.wikipediaUrl,
website_url: Option.getOrNull(publicFigure.websiteUrl),
wikipedia_url: Option.getOrNull(publicFigure.wikipediaUrl),
notoriety_sources: publicFigure.notorietySources,
created_by: publicFigure.createdBy,
})
.select()
@ -181,8 +186,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: data.name,
slug: data.slug,
presentation: data.presentation,
websiteUrl: data.website_url ? Option.some(data.website_url) : Option.none(),
wikipediaUrl: data.wikipedia_url,
websiteUrl: Option.fromNullable(data.website_url),
wikipediaUrl: Option.fromNullable(data.wikipedia_url),
notorietySources: data.notoriety_sources ?? [],
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
createdBy: data.created_by,
@ -200,10 +206,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: publicFigure.name,
slug: publicFigure.slug,
presentation: publicFigure.presentation,
website_url: Option.isSome(publicFigure.websiteUrl)
? publicFigure.websiteUrl.value
: null,
wikipedia_url: publicFigure.wikipediaUrl,
website_url: Option.getOrNull(publicFigure.websiteUrl),
wikipedia_url: Option.getOrNull(publicFigure.wikipediaUrl),
notoriety_sources: publicFigure.notorietySources,
})
.eq('id', publicFigure.id)
.select()
@ -216,8 +221,9 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
name: data.name,
slug: data.slug,
presentation: data.presentation,
websiteUrl: data.website_url ? Option.some(data.website_url) : Option.none(),
wikipediaUrl: data.wikipedia_url,
websiteUrl: Option.fromNullable(data.website_url),
wikipediaUrl: Option.fromNullable(data.wikipedia_url),
notorietySources: data.notoriety_sources ?? [],
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
createdBy: data.created_by,
@ -273,5 +279,57 @@ export function createPublicFigureRepository(supabase: SupabaseClient): PublicFi
},
catch: (error) => dbError('Failed to get public figure stats', error),
}),
findSummariesByActivity: (limit: number, orderBy = 'latest_statement_at' as const) =>
Effect.tryPromise({
try: async () => {
const { data, error } = await supabase
.from('v_public_figure_activity_summary')
.select('*')
.order(orderBy, { ascending: false, nullsFirst: false })
.limit(limit)
if (error) throw error
return data.map(
(row): PublicFigureActivitySummary => ({
id: row.id,
name: row.name,
slug: row.slug,
presentation: row.presentation,
statementsCount: row.statements_count ?? 0,
subjectsCount: row.subjects_count ?? 0,
latestStatementAt: row.latest_statement_at ? new Date(row.latest_statement_at) : null,
}),
)
},
catch: (error) => dbError('Failed to fetch public figure summaries', error),
}),
findByLetter: (letter: string) =>
Effect.tryPromise({
try: async () => {
const { data, error } = await supabase
.from('v_public_figure_activity_summary')
.select('*')
.ilike('name', `${letter}%`)
.order('name')
if (error) throw error
return data.map(
(row): PublicFigureActivitySummary => ({
id: row.id,
name: row.name,
slug: row.slug,
presentation: row.presentation,
statementsCount: row.statements_count ?? 0,
subjectsCount: row.subjects_count ?? 0,
latestStatementAt: row.latest_statement_at ? new Date(row.latest_statement_at) : null,
}),
)
},
catch: (error) => dbError('Failed to fetch public figures by letter', error),
}),
}
}

View file

@ -237,6 +237,7 @@ export function createStatementRepository(supabase: SupabaseClient<Database>): S
slug,
presentation,
wikipedia_url,
notoriety_sources,
website_url,
created_by,
created_at,
@ -272,10 +273,9 @@ export function createStatementRepository(supabase: SupabaseClient<Database>): S
name: PublicFigureName.make(row.public_figures.name),
slug: PublicFigureSlug.make(row.public_figures.slug),
presentation: row.public_figures.presentation,
wikipediaUrl: row.public_figures.wikipedia_url,
websiteUrl: row.public_figures.website_url
? Option.some(row.public_figures.website_url)
: Option.none(),
wikipediaUrl: Option.fromNullable(row.public_figures.wikipedia_url),
notorietySources: row.public_figures.notoriety_sources ?? [],
websiteUrl: Option.fromNullable(row.public_figures.website_url),
createdBy: row.public_figures.created_by,
createdAt: new Date(row.public_figures.created_at!),
updatedAt: new Date(row.public_figures.updated_at!),

View file

@ -176,7 +176,7 @@ export function createSubjectRepository(supabase: SupabaseClient): SubjectReposi
Effect.tryPromise({
try: async () => {
const { data, error } = await supabase
.from('subject_activity_summary')
.from('v_subject_activity_summary')
.select('*')
.order('latest_statement_at', { ascending: false, nullsFirst: false })
.limit(limit)

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -0,0 +1,14 @@
-- Rendre wikipedia_url optionnel et ajouter notoriety_sources
-- La validation métier (notoriété) est dans le domain layer
-- Supprimer la contrainte CHECK métier sur wikipedia_url
ALTER TABLE public_figures DROP CONSTRAINT IF EXISTS wikipedia_url_valid;
-- Rendre wikipedia_url nullable
ALTER TABLE public_figures ALTER COLUMN wikipedia_url DROP NOT NULL;
-- Ajouter la colonne notoriety_sources (tableau d'URLs)
ALTER TABLE public_figures ADD COLUMN notoriety_sources TEXT[] DEFAULT '{}';
COMMENT ON COLUMN public_figures.wikipedia_url IS 'URL de la page Wikipedia (optionnel)';
COMMENT ON COLUMN public_figures.notoriety_sources IS 'URLs de sources indépendantes attestant la notoriété';

View file

@ -0,0 +1,26 @@
-- Rename existing subject view to follow v_ convention
ALTER VIEW subject_activity_summary RENAME TO v_subject_activity_summary;
-- Read model: public figure summaries with activity stats
-- Used by the /p page to display figures ordered by activity
CREATE VIEW v_public_figure_activity_summary AS
SELECT
pf.id,
pf.name,
pf.slug,
pf.presentation,
COALESCE(agg.statements_count, 0)::integer AS statements_count,
COALESCE(agg.subjects_count, 0)::integer AS subjects_count,
agg.latest_statement_at
FROM public_figures pf
LEFT JOIN LATERAL (
SELECT
COUNT(DISTINCT s.id) AS statements_count,
COUNT(DISTINCT p.subject_id) AS subjects_count,
MAX(s.taken_at) AS latest_statement_at
FROM statements s
JOIN positions p ON s.position_id = p.id
WHERE s.public_figure_id = pf.id
) agg ON true;
GRANT SELECT ON v_public_figure_activity_summary TO anon, authenticated;

View file

@ -110,14 +110,14 @@ export type Database = {
foreignKeyName: "arguments_subject_id_fkey"
columns: ["subject_id"]
isOneToOne: false
referencedRelation: "subject_activity_summary"
referencedRelation: "subjects"
referencedColumns: ["id"]
},
{
foreignKeyName: "arguments_subject_id_fkey"
columns: ["subject_id"]
isOneToOne: false
referencedRelation: "subjects"
referencedRelation: "v_subject_activity_summary"
referencedColumns: ["id"]
},
]
@ -282,14 +282,14 @@ export type Database = {
foreignKeyName: "positions_subject_id_fkey"
columns: ["subject_id"]
isOneToOne: false
referencedRelation: "subject_activity_summary"
referencedRelation: "subjects"
referencedColumns: ["id"]
},
{
foreignKeyName: "positions_subject_id_fkey"
columns: ["subject_id"]
isOneToOne: false
referencedRelation: "subjects"
referencedRelation: "v_subject_activity_summary"
referencedColumns: ["id"]
},
]
@ -300,33 +300,36 @@ export type Database = {
created_by: string
id: string
name: string
notoriety_sources: string[] | null
presentation: string
slug: string
updated_at: string | null
website_url: string | null
wikipedia_url: string
wikipedia_url: string | null
}
Insert: {
created_at?: string | null
created_by: string
id?: string
name: string
notoriety_sources?: string[] | null
presentation: string
slug: string
updated_at?: string | null
website_url?: string | null
wikipedia_url: string
wikipedia_url?: string | null
}
Update: {
created_at?: string | null
created_by?: string
id?: string
name?: string
notoriety_sources?: string[] | null
presentation?: string
slug?: string
updated_at?: string | null
website_url?: string | null
wikipedia_url?: string
wikipedia_url?: string | null
}
Relationships: [
{
@ -338,6 +341,44 @@ export type Database = {
},
]
}
reputation_events: {
Row: {
action: string
amount: number
contributor_id: string
created_at: string | null
id: string
related_entity_id: string | null
related_entity_type: string | null
}
Insert: {
action: string
amount: number
contributor_id: string
created_at?: string | null
id?: string
related_entity_id?: string | null
related_entity_type?: string | null
}
Update: {
action?: string
amount?: number
contributor_id?: string
created_at?: string | null
id?: string
related_entity_id?: string | null
related_entity_type?: string | null
}
Relationships: [
{
foreignKeyName: "reputation_events_contributor_id_fkey"
columns: ["contributor_id"]
isOneToOne: false
referencedRelation: "contributors"
referencedColumns: ["id"]
},
]
}
statements: {
Row: {
created_at: string | null
@ -388,6 +429,13 @@ export type Database = {
referencedRelation: "public_figures"
referencedColumns: ["id"]
},
{
foreignKeyName: "statements_public_figure_id_fkey"
columns: ["public_figure_id"]
isOneToOne: false
referencedRelation: "v_public_figure_activity_summary"
referencedColumns: ["id"]
},
]
}
subjects: {
@ -436,7 +484,19 @@ export type Database = {
}
}
Views: {
subject_activity_summary: {
v_public_figure_activity_summary: {
Row: {
id: string | null
latest_statement_at: string | null
name: string | null
presentation: string | null
slug: string | null
statements_count: number | null
subjects_count: number | null
}
Relationships: []
}
v_subject_activity_summary: {
Row: {
created_at: string | null
created_by: string | null