feat: page profil /me avec dashboard compact
- Identité (nom, email) avec bouton Inviter - Carte réputation avec score, rang, barre de progression vers le prochain rang - Lien vers l'historique de réputation - Le nom dans le header devient un lien vers /me - Ajout getNextRankThreshold dans permissions
This commit is contained in:
parent
5d4fdcbd14
commit
3b63a7b98b
5 changed files with 209 additions and 7 deletions
|
|
@ -17,5 +17,10 @@ export async function getAuthenticatedContributor() {
|
|||
|
||||
if (!data) return null
|
||||
|
||||
return { id: data.id, reputation: data.reputation ?? 0 }
|
||||
return {
|
||||
id: data.id,
|
||||
reputation: data.reputation ?? 0,
|
||||
name: (user.user_metadata?.name as string) ?? null,
|
||||
email: user.email ?? null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
app/me/me.module.css
Normal file
122
app/me/me.module.css
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
.title {
|
||||
font-family: var(--font-gotham-bold);
|
||||
font-size: 2em;
|
||||
color: var(--debats-red);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.identityInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.displayName {
|
||||
font-family: var(--font-gotham-bold);
|
||||
font-size: 1.2em;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: var(--font-gotham-book);
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.reputationCard {
|
||||
background: var(--bg-light, #fafafa);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.reputationHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reputationScore {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-family: var(--font-gotham-bold);
|
||||
font-size: 2em;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.scoreLabel {
|
||||
font-family: var(--font-gotham-book);
|
||||
font-size: 14px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rank {
|
||||
font-family: var(--font-gotham-bold);
|
||||
font-size: 14px;
|
||||
color: var(--debats-red);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 6px;
|
||||
background: var(--border-light);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--debats-red);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-family: var(--font-gotham-book);
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.historyLink {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
font-family: var(--font-gotham-book);
|
||||
font-size: 13px;
|
||||
color: var(--debats-red);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.historyLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.identity {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.reputationHeader {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
71
app/me/page.tsx
Normal file
71
app/me/page.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getAuthenticatedContributor } from '../actions/get-authenticated-contributor'
|
||||
import { getRank, getNextRankThreshold } from '../../domain/reputation/permissions'
|
||||
import ContentWithSidebar from '../../components/layout/ContentWithSidebar'
|
||||
import Button from '../../components/ui/Button'
|
||||
import styles from './me.module.css'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Mon compte — Débats.co',
|
||||
}
|
||||
|
||||
function formatScore(n: number): string {
|
||||
return n.toLocaleString('fr-FR')
|
||||
}
|
||||
|
||||
export default async function MePage() {
|
||||
const contributor = await getAuthenticatedContributor()
|
||||
|
||||
if (!contributor) {
|
||||
redirect(
|
||||
'/?notice=' + encodeURIComponent('Vous devez être connecté·e pour accéder à votre profil.'),
|
||||
)
|
||||
}
|
||||
|
||||
const displayName = contributor.name ?? contributor.email ?? 'Contributeur·rice'
|
||||
const rank = getRank(contributor.reputation)
|
||||
const nextThreshold = getNextRankThreshold(contributor.reputation)
|
||||
const progress = nextThreshold
|
||||
? Math.min((contributor.reputation / nextThreshold) * 100, 100)
|
||||
: 100
|
||||
|
||||
return (
|
||||
<ContentWithSidebar topMargin>
|
||||
<h1 className={styles.title}>Mon compte</h1>
|
||||
|
||||
<div className={styles.identity}>
|
||||
<div className={styles.identityInfo}>
|
||||
<span className={styles.displayName}>{displayName}</span>
|
||||
{contributor.name && contributor.email && (
|
||||
<span className={styles.email}>{contributor.email}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button href="/inviter" size="small">
|
||||
Inviter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.reputationCard}>
|
||||
<div className={styles.reputationHeader}>
|
||||
<div className={styles.reputationScore}>
|
||||
<span className={styles.score}>{formatScore(contributor.reputation)}</span>
|
||||
<span className={styles.scoreLabel}>points</span>
|
||||
</div>
|
||||
<span className={styles.rank}>{rank}</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
{nextThreshold && (
|
||||
<span className={styles.progressLabel}>
|
||||
{formatScore(nextThreshold - contributor.reputation)} pts avant le prochain rang
|
||||
</span>
|
||||
)}
|
||||
<Link href="/reputation" className={styles.historyLink}>
|
||||
Voir l'historique
|
||||
</Link>
|
||||
</div>
|
||||
</ContentWithSidebar>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,12 +30,8 @@ export default function AuthSection() {
|
|||
const displayName = user.user_metadata?.name || user.email
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.userName}>{displayName}</span>
|
||||
<Link href="/reputation" className={styles.inviteLink}>
|
||||
Réputation
|
||||
</Link>
|
||||
<Link href="/inviter" className={styles.inviteLink}>
|
||||
Inviter
|
||||
<Link href="/me" className={styles.userName}>
|
||||
{displayName}
|
||||
</Link>
|
||||
<Button
|
||||
variant="link"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ const RANK_THRESHOLDS = {
|
|||
[Rank.Fondateur]: 1000000,
|
||||
} as const
|
||||
|
||||
export function getNextRankThreshold(reputation: number): number | null {
|
||||
if (reputation < 0) return 0
|
||||
if (reputation < RANK_THRESHOLDS[Rank.Eloquent]) return RANK_THRESHOLDS[Rank.Eloquent]
|
||||
if (reputation < RANK_THRESHOLDS[Rank.Idealiste]) return RANK_THRESHOLDS[Rank.Idealiste]
|
||||
if (reputation < RANK_THRESHOLDS[Rank.Fondateur]) return RANK_THRESHOLDS[Rank.Fondateur]
|
||||
return null
|
||||
}
|
||||
|
||||
export function getRank(reputation: number): Rank {
|
||||
if (reputation < 0) return Rank.Sophiste
|
||||
if (reputation >= RANK_THRESHOLDS[Rank.Fondateur]) return Rank.Fondateur
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue