Compare commits
3 commits
23217946af
...
5d89a81723
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d89a81723 | |||
| 7f9efc5ee2 | |||
| fcfe77117a |
23 changed files with 703 additions and 218 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
92
README.md
92
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'autorégulation,
|
||||
permettant d'éviter la publication de contenu considérés comme fallacieux ou
|
||||
hors-sujet. Chaque entrée, notamment lors du référencement d'une nouvelle
|
||||
personnalité ou d'un nouveau sujet, sont soumis à l'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'admissibilité de
|
||||
Wikipedia : une personnalité doit avoir fait l'objet d'au moins deux
|
||||
publications dans des sources indépendantes et fiables (article de presse, page
|
||||
institutionnelle, rapport officiel…). Avoir une page Wikipedia n'est pas obligatoire,
|
||||
mais reste un enrichissement recommandé. Ce critère permet d'inclure des
|
||||
personnalités locales ou sectorielles (dirigeant·es d'institution, porte-paroles
|
||||
d'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'en rapprocher, il est nécessaire pour chaque utilisateur de se demander
|
||||
comment exprimer de façon objective l'intitulé des sujets ou les positions des
|
||||
personnalités. Dans un premier temps, et pour simplifier cette démarche
|
||||
"objective", 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'intitulé de la personnalité ou du sujet, de
|
||||
sélectionner l'élément correspondant.
|
||||
personnalités.
|
||||
</p>
|
||||
<p>
|
||||
Lors de la recension d'une prise d'une position ensuite, il est important
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
152
app/p/page.tsx
152
app/p/page.tsx
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
62
components/figures/PersonalitySearch/index.tsx
Normal file
62
components/figures/PersonalitySearch/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
12
domain/value-objects/public-figure-activity-summary.ts
Normal file
12
domain/value-objects/public-figure-activity-summary.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!),
|
||||
|
|
|
|||
|
|
@ -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
2
next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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é';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue