Ajoute les suggestions de questions des Citoyens et les emails sur Admin
Build and deploy / deploy (push) Successful in 35s

Chaque Citoyen peut proposer une question pour Le Char depuis /profile
(upsert via submit_chariot_question, figée dès la date du Tribunal
comme les scores d'Icare). Les Archontes modèrent ces suggestions
directement sur /char/questions (ajouter à la banque ou rejeter).

/admin affiche maintenant l'email de chaque membre via une nouvelle
RPC admin_list_members(), seule façon d'exposer auth.users.email sans
passer par la service_role key côté client.

Corrige au passage deux bugs découverts pendant les tests : la
policy interne de admin_list_members() référençait id/role sans les
qualifier, ambigus avec les colonnes du RETURNS TABLE (erreur
Postgres 42702) ; et l'auteur d'une suggestion s'affichait toujours
comme "un Citoyen" car le embed profiles(...) avait été mal retypé en
tableau alors qu'il est retourné en objet à l'exécution.
This commit is contained in:
Valentin ROBIN
2026-08-19 01:22:36 +02:00
parent 0c4814f4d6
commit 76fa8cd451
8 changed files with 411 additions and 56 deletions
+12 -8
View File
@@ -12,6 +12,7 @@ type Member = {
role: string;
pseudo_locked: boolean;
avatar_url: string | null;
email: string;
};
function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
@@ -78,14 +79,17 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
return (
<li className="marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
<div className="flex items-center gap-2">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<input
type="text"
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto"
/>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<input
type="text"
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto"
/>
</div>
<p className="truncate pl-10 text-xs text-text-mut">{member.email}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
+1 -4
View File
@@ -25,10 +25,7 @@ export default async function AdminPage() {
redirect("/leaderboard");
}
const { data: members } = await supabase
.from("profiles")
.select("id, pseudo, role, pseudo_locked, avatar_url")
.order("pseudo", { ascending: true });
const { data: members } = await supabase.rpc("admin_list_members");
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
+14
View File
@@ -10,6 +10,13 @@ export type QuestionRow = {
created_at: string;
};
export type SubmissionRow = {
user_id: string;
text: string;
updated_at: string;
profiles: { pseudo: string; avatar_url: string | null } | null;
};
export default async function CharQuestionsPage() {
const supabase = await createClient();
const {
@@ -45,6 +52,12 @@ export default async function CharQuestionsPage() {
.eq("id", true)
.single();
const { data: submissions } = await supabase
.from("chariot_submissions")
.select("user_id, text, updated_at, profiles(pseudo, avatar_url)")
.order("updated_at", { ascending: true })
.returns<SubmissionRow[]>();
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
@@ -62,6 +75,7 @@ export default async function CharQuestionsPage() {
<QuestionsView
initialQuestions={questions ?? []}
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
initialSubmissions={submissions ?? []}
/>
</div>
);
+138 -40
View File
@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@/components/icons";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { QuestionRow } from "./page";
import type { QuestionRow, SubmissionRow } from "./page";
function QuestionForm({
initial,
@@ -184,29 +185,91 @@ function QuestionRowItem({
);
}
function SubmissionRowItem({
submission,
onAddToBank,
onReject,
}: {
submission: SubmissionRow;
onAddToBank: () => void;
onReject: () => void;
}) {
const [confirmingReject, setConfirmingReject] = useState(false);
const profile = submission.profiles;
const pseudo = profile?.pseudo ?? "un Citoyen";
return (
<li className="flex items-start gap-2 rounded-md px-2 py-1.5 text-sm">
<Avatar pseudo={pseudo} avatarUrl={profile?.avatar_url ?? null} size="sm" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-text-mut">{pseudo}</p>
<p className="text-text-marble">{submission.text}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onAddToBank}
className="rounded-md border border-gold/40 px-2 py-1 text-xs font-medium text-text-marble hover:bg-gold/10"
>
Ajouter à la banque
</button>
{confirmingReject ? (
<button
type="button"
onClick={onReject}
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
>
Confirmer ?
</button>
) : (
<button
type="button"
onClick={() => setConfirmingReject(true)}
onBlur={() => setConfirmingReject(false)}
aria-label="Rejeter"
title="Rejeter"
className="flex h-7 w-7 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
>
<IconTrash className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
}
export function QuestionsView({
initialQuestions,
initialRevealedId,
initialSubmissions,
}: {
initialQuestions: QuestionRow[];
initialRevealedId: string | null;
initialSubmissions: SubmissionRow[];
}) {
const [questions, setQuestions] = useState(initialQuestions);
const [revealedId, setRevealedId] = useState(initialRevealedId);
const [submissions, setSubmissions] = useState(initialSubmissions);
const [adding, setAdding] = useState(false);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: q }, { data: s }] = await Promise.all([
const [{ data: q }, { data: s }, { data: sub }] = await Promise.all([
supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true }),
supabase.from("settings").select("chariot_revealed_question_id").eq("id", true).single(),
supabase
.from("chariot_submissions")
.select("user_id, text, updated_at, profiles(pseudo, avatar_url)")
.order("updated_at", { ascending: true })
.returns<SubmissionRow[]>(),
]);
if (q) setQuestions(q);
if (s) setRevealedId(s.chariot_revealed_question_id);
if (sub) setSubmissions(sub);
}, []);
useEffect(() => {
@@ -220,6 +283,7 @@ export function QuestionsView({
.channel("char-questions-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_submissions" }, refetch)
.subscribe();
});
@@ -250,46 +314,80 @@ export function QuestionsView({
refetch();
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{questions.length === 0 && !adding && (
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
)}
<ul className="flex flex-col divide-y divide-gold/10">
{questions.map((question, index) => (
<QuestionRowItem
key={question.id}
question={question}
orderNumber={index + 1}
isFirst={index === 0}
isLast={index === questions.length - 1}
isRevealed={revealedId === question.id}
onMove={(direction) => moveQuestion(question, direction)}
onToggleReveal={() => toggleReveal(question)}
onChanged={refetch}
/>
))}
</ul>
async function addSubmissionToBank(submission: SubmissionRow) {
const supabase = createClient();
const nextPosition = (questions.at(-1)?.position ?? 0) + 1;
await supabase.from("chariot_questions").insert({ text: submission.text, position: nextPosition });
await supabase.from("chariot_submissions").delete().eq("user_id", submission.user_id);
refetch();
}
<div className="mt-3">
{adding ? (
<QuestionForm
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
onDone={() => {
setAdding(false);
refetch();
}}
/>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
>
<IconPlus className="h-3.5 w-3.5" />
Ajouter une question
</button>
async function rejectSubmission(userId: string) {
const supabase = createClient();
await supabase.from("chariot_submissions").delete().eq("user_id", userId);
refetch();
}
return (
<div className="flex flex-col gap-4">
{submissions.length > 0 && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
<p className="mb-2 font-heading text-sm tracking-wide text-text-mut uppercase">
Suggestions des Citoyens
</p>
<ul className="flex flex-col divide-y divide-gold/10">
{submissions.map((submission) => (
<SubmissionRowItem
key={submission.user_id}
submission={submission}
onAddToBank={() => addSubmissionToBank(submission)}
onReject={() => rejectSubmission(submission.user_id)}
/>
))}
</ul>
</div>
)}
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{questions.length === 0 && !adding && (
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
)}
<ul className="flex flex-col divide-y divide-gold/10">
{questions.map((question, index) => (
<QuestionRowItem
key={question.id}
question={question}
orderNumber={index + 1}
isFirst={index === 0}
isLast={index === questions.length - 1}
isRevealed={revealedId === question.id}
onMove={(direction) => moveQuestion(question, direction)}
onToggleReveal={() => toggleReveal(question)}
onChanged={refetch}
/>
))}
</ul>
<div className="mt-3">
{adding ? (
<QuestionForm
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
onDone={() => {
setAdding(false);
refetch();
}}
/>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
>
<IconPlus className="h-3.5 w-3.5" />
Ajouter une question
</button>
)}
</div>
</div>
</div>
);
@@ -0,0 +1,87 @@
"use client";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
const MAX_LENGTH = 300;
export function ChariotSubmissionForm({
initialText,
frozen,
}: {
initialText: string;
frozen: boolean;
}) {
const [text, setText] = useState(initialText);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
if (frozen) {
return (
<div>
<p className="rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
{initialText || "Tu n'as proposé aucune question."}
</p>
<p className="mt-2 text-xs text-text-mut">Les propositions sont closes, l&apos;Agora a commencé.</p>
</div>
);
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSuccess(false);
const trimmed = text.trim();
if (!trimmed) {
setError("Écris une question avant d'enregistrer.");
return;
}
setSaving(true);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("submit_chariot_question", { p_text: trimmed });
setSaving(false);
if (rpcError) {
setError("Une erreur est survenue, réessaie.");
return;
}
setSuccess(true);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<textarea
value={text}
onChange={(event) => {
setText(event.target.value);
setSuccess(false);
}}
maxLength={MAX_LENGTH}
rows={3}
placeholder="Écrire une question…"
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
/>
<p className="text-right text-[0.65rem] text-text-mut">
{text.length}/{MAX_LENGTH}
</p>
{error && (
<p role="alert" className="text-sm text-oxblood">
{error}
</p>
)}
{success && <p className="text-sm text-olive">Question enregistrée.</p>}
<button
type="submit"
disabled={saving}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{saving ? "Enregistrement…" : "Enregistrer"}
</button>
</form>
);
}
+21
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { ProfileForm } from "./profile-form";
import { ChariotSubmissionForm } from "./chariot-submission-form";
export default async function ProfilePage() {
const supabase = await createClient();
@@ -20,6 +21,17 @@ export default async function ProfilePage() {
const isJudge = profile?.role === "judge";
let submissionText = "";
let frozen = false;
if (!isJudge) {
const [{ data: submission }, { data: settings }] = await Promise.all([
supabase.from("chariot_submissions").select("text").eq("user_id", user.id).maybeSingle(),
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
]);
submissionText = submission?.text ?? "";
frozen = Boolean(settings?.tribunal_date) && new Date(settings!.tribunal_date!) <= new Date();
}
return (
<div className="mx-auto w-full max-w-sm px-4 py-8">
<div className="mb-6 text-center">
@@ -44,6 +56,15 @@ export default async function ProfilePage() {
initialPseudoLocked={profile?.pseudo_locked ?? false}
/>
</div>
{!isJudge && (
<div className="marble-surface mt-4 rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
Ma question pour Le Char
</h2>
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
</div>
)}
</div>
);
}