398 lines
13 KiB
Rust
398 lines
13 KiB
Rust
use futures::future;
|
|
use futures::stream::StreamExt;
|
|
use gloo_timers::future::IntervalStream;
|
|
use rand::distributions::Alphanumeric;
|
|
use rand::prelude::*;
|
|
use sycamore::builder::prelude::*;
|
|
use sycamore::futures::spawn_local_scoped;
|
|
use sycamore::prelude::*;
|
|
use sycamore::rt::JsCast;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum ModeQueue {
|
|
Chrono,
|
|
Aléatoire,
|
|
}
|
|
|
|
impl ModeQueue {
|
|
fn set_aléatoire(&mut self, b: bool) {
|
|
match b {
|
|
false => *self = ModeQueue::Chrono,
|
|
true => *self = ModeQueue::Aléatoire,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ModeQueue {
|
|
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
|
|
let s = match self {
|
|
ModeQueue::Chrono => "Chrono",
|
|
ModeQueue::Aléatoire => "Aléatoire",
|
|
};
|
|
write!(fmt, "{}", s)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ÉtatQueue<'a> {
|
|
nom: String,
|
|
id: usize,
|
|
mode: &'a Signal<ModeQueue>,
|
|
contenu: &'a Signal<Vec<String>>,
|
|
nouvelleau: &'a Signal<String>,
|
|
priorité: &'a Signal<u8>,
|
|
crédit_parole: &'a Signal<f32>,
|
|
à_détruire: &'a Signal<bool>,
|
|
}
|
|
|
|
impl<'a> PartialEq for ÉtatQueue<'a> {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.id == other.id
|
|
}
|
|
}
|
|
|
|
impl<'a> Eq for ÉtatQueue<'a> {}
|
|
|
|
impl<'a> ÉtatQueue<'a> {
|
|
fn new<'b>(cx: BoundedScope<'a, 'b>, nom: &str) -> Self {
|
|
ÉtatQueue {
|
|
nom: nom.into(),
|
|
mode: create_signal(cx, ModeQueue::Aléatoire),
|
|
contenu: create_signal(cx, vec![]),
|
|
nouvelleau: create_signal(cx, "".into()),
|
|
priorité: create_signal(cx, 1),
|
|
crédit_parole: create_signal(cx, 0.0),
|
|
id: thread_rng().gen(),
|
|
à_détruire: create_signal(cx, false),
|
|
}
|
|
}
|
|
|
|
fn len(&self) -> usize {
|
|
return self.contenu.get().len();
|
|
}
|
|
|
|
fn enqueue(&mut self, rng: &mut ThreadRng) {
|
|
//
|
|
let s = &self.nouvelleau;
|
|
let n = self.len();
|
|
let k = match *self.mode.get() {
|
|
ModeQueue::Chrono => n,
|
|
ModeQueue::Aléatoire => rng.gen_range(0, n + 1),
|
|
};
|
|
self.contenu.modify().insert(k, s.to_string())
|
|
}
|
|
|
|
fn dequeue(&mut self) -> Option<String> {
|
|
*self.crédit_parole.modify() -= 1.0;
|
|
if self.len() > 0 {
|
|
Some(self.contenu.modify().remove(0))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Prop)]
|
|
struct QueueProp<'a> {
|
|
q: ÉtatQueue<'a>,
|
|
}
|
|
|
|
fn validate_queue<'a>(mut queue: ÉtatQueue<'a>) -> impl FnMut(web_sys::Event) + 'a {
|
|
move |ev: web_sys::Event| {
|
|
let mut rng = thread_rng();
|
|
let event: web_sys::KeyboardEvent = ev.unchecked_into();
|
|
|
|
if event.key_code() == 13 && !event.ctrl_key() {
|
|
queue.enqueue(&mut rng);
|
|
queue.nouvelleau.set(String::new())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn ConfigQueue<'a, G: Html>(cx: Scope<'a>, q: QueueProp<'a>) -> View<G> {
|
|
let queue: ÉtatQueue = q.q;
|
|
let prio_str = create_signal(cx, String::new());
|
|
let est_aléatoire = create_signal(cx, true);
|
|
let nom = queue.nom;
|
|
let suffix = rand::thread_rng()
|
|
.sample_iter(&Alphanumeric)
|
|
.take(15)
|
|
.map(char::from)
|
|
.collect::<String>();
|
|
|
|
create_effect(cx, || {
|
|
if let Ok(p) = prio_str.get().parse::<u8>() {
|
|
queue.priorité.set(p)
|
|
}
|
|
});
|
|
create_effect(cx, || prio_str.set(format!("{}", queue.priorité.get())));
|
|
|
|
create_effect(cx, || {
|
|
queue.mode.modify().set_aléatoire(*est_aléatoire.get())
|
|
});
|
|
create_effect(cx, || {
|
|
est_aléatoire.set(*queue.mode.get() == ModeQueue::Aléatoire)
|
|
});
|
|
|
|
section()
|
|
.c(h3().dyn_t(move || format!("{} ", nom)).c(button()
|
|
.t("Supprimer")
|
|
.on("click", move |_ev| (queue.à_détruire.set(true)))))
|
|
.c(input()
|
|
.attr("type", "checkbox")
|
|
.dyn_attr("id", {
|
|
let suffix = suffix.clone();
|
|
move || Some(format!("mode_queue_{}", &suffix.clone()))
|
|
})
|
|
.bind_checked(est_aléatoire))
|
|
.c(label()
|
|
.dyn_attr("for", {
|
|
let suffix = suffix.clone();
|
|
move || Some(format!("mode_queue_{}", &suffix.clone()))
|
|
})
|
|
.t("Entrée aléatoire"))
|
|
.c(br())
|
|
.c(input()
|
|
.dyn_attr("id", {
|
|
let suffix = suffix.clone();
|
|
move || Some(format!("prio_queue_{}", &suffix.clone()))
|
|
})
|
|
.attr("type", "range")
|
|
.attr("min", "1")
|
|
.attr("max", "7")
|
|
.bind_value(prio_str))
|
|
.c(label()
|
|
.dyn_attr("for", {
|
|
let suffix = suffix.clone();
|
|
move || Some(format!("prio_queue_{}", &suffix.clone()))
|
|
})
|
|
.dyn_t(|| format!("Priorité: {}", queue.priorité.get())))
|
|
.view(cx)
|
|
}
|
|
|
|
#[component]
|
|
fn Queue<'a, G: Html>(cx: Scope<'a>, queue: QueueProp<'a>) -> View<G> {
|
|
let ok_queue = validate_queue(queue.q.clone());
|
|
view! { cx,
|
|
div {
|
|
section(class="queue") {
|
|
h3 {(format!("{}", queue.q.nom))}
|
|
input(type="text", bind:value = queue.q.nouvelleau, on:keypress=ok_queue) {}
|
|
ul {
|
|
Keyed {
|
|
iterable: queue.q.contenu,
|
|
view: |cx, item: String| view! {cx, li { (item) }},
|
|
key: |x| x.clone()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Prop, Clone, Copy)]
|
|
struct BarreCôtéProp<'a> {
|
|
queues: &'a Signal<Vec<ÉtatQueue<'a>>>,
|
|
}
|
|
|
|
fn classe_visible(b: bool) -> &'static str {
|
|
if b {
|
|
"visible"
|
|
} else {
|
|
"hidden"
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn Explications<'a, G: Html>(cx: Scope<'a>) -> View<G> {
|
|
details()
|
|
.class("aide")
|
|
.c(summary().t("Comment utiliser Kikikoz"))
|
|
.c(p().t("Kikikoz est un outil qui vous aide à répartir la parole dans une réunion. Il s'exécute en toute discrétion dans votre navigateur: ").c(strong().t("il n'envoie aucune donnée où que ce soit.")))
|
|
.c(p().t("Quand vous lancez Kikikoz, l'interface que vous voyez correspond à une réunion avec un tour de parole divisé en deux files. C'est une pratique que l'on observe souvent, notamment pour répartir équitablement la parole entre femmes et hommes."))
|
|
.c(p().t("Quand une personne lève la main, inscrivez son nom dans le champ de texte correspondant à la file d'attente idoine, puis pressez ENTRÉE."))
|
|
.c(p().t("Pour donner la parole à la personne suivante, il suffit de cliquer sur le bouton \"suivantə\", ou de taper CTRL+ENTRÉE. Son nom et son temps de parole apparaissent alors en gros caractères"))
|
|
.c(p().t("La parole alterne entre les deux files. La prochaine personne à parler est donc celle qui est en tête de la file avec un fond vert et un titre en italique. Vous l'avez peut-être remarqué, quand une personne lève la main, elle ne va pas à la fin de la file, mais à une position aléatoire. C'est inhabituel, mais pas moins juste. Il y a des chances que ce qu'elle à a dire soit plus pertinent, puisqu'elle lève la main après avoir entendu plus de choses. Vous pouvez changer ce comportement pour chaque file."))
|
|
.c(p().t("Pour faire une pause dans la réunion (quand personne n'a la parole), cliquez sur \"pause\"."))
|
|
.c(p().t("Vous pouvez configurer toutes sortes de détails via le panneau configaration. Vous pouvez y ajouter ou supprimer des files, décider si l'ajout de personnes dans chaque file doit se faire à une position aléatoire ou à la fin. Vous pouvez également attribuer une priorité différente aux files, si vous décidez que la parole doit revenir à l'une d'entre elles plus souvent qu'aux autres."))
|
|
.view(cx)
|
|
}
|
|
|
|
#[component]
|
|
fn BarreCôté<'a, G: Html>(cx: Scope<'a>, p: BarreCôtéProp<'a>) -> View<G> {
|
|
let conf_visible = create_signal(cx, false);
|
|
let nom_nouvelle_file = create_signal(cx, String::new());
|
|
let nouvelle_file = {
|
|
let queues = p.queues.clone();
|
|
move |_ev| {
|
|
let q = ÉtatQueue::new(cx, &nom_nouvelle_file.get());
|
|
queues.modify().push(q);
|
|
*nom_nouvelle_file.modify() = String::new();
|
|
}
|
|
};
|
|
let nouvelle_file_clav = move |ev: web_sys::Event| {
|
|
let event: web_sys::KeyboardEvent = ev.clone().unchecked_into();
|
|
if event.key_code() == 13 && !event.ctrl_key() {
|
|
nouvelle_file(ev)
|
|
}
|
|
};
|
|
|
|
view! {cx,
|
|
aside {
|
|
|
|
details(class="configuration") {
|
|
summary { "Configuration " }
|
|
|
|
Keyed {
|
|
iterable: p.queues,
|
|
key: |q| q.id,
|
|
view: |cx, q| view!{cx, ConfigQueue{ q }}
|
|
}
|
|
|
|
section {
|
|
h3 {"Nouvelle file" }
|
|
input(type="text", bind:value=nom_nouvelle_file, on:keypress=nouvelle_file_clav) {}
|
|
button(on:click=nouvelle_file) { "Créer" }
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
sycamore::render(|cx| {
|
|
let heure_courante = create_signal(cx, 0.0);
|
|
let _ = spawn_local_scoped(cx, async {
|
|
IntervalStream::new(1000)
|
|
.for_each(|_| {
|
|
heure_courante.set(js_sys::Date::now());
|
|
future::ready(())
|
|
})
|
|
.await
|
|
});
|
|
|
|
let date_prise_parole = create_signal(cx, 0.0);
|
|
let courantə = create_signal(cx, None);
|
|
// let courantə2 = create_signal(cx, None).clone();
|
|
let queues = create_signal(
|
|
cx,
|
|
vec![ÉtatQueue::new(cx, "Gauche"), ÉtatQueue::new(cx, "Droite")],
|
|
);
|
|
create_effect(cx, || {
|
|
for q in queues.get().iter() {
|
|
q.à_détruire.track()
|
|
}
|
|
queues.modify().retain(|q| !(*q.à_détruire.get()));
|
|
});
|
|
|
|
let total_priorités: &ReadSignal<usize> = create_memo(cx, {
|
|
let queues = queues.clone();
|
|
move || {
|
|
queues
|
|
.get()
|
|
.iter()
|
|
.map(|q| *q.priorité.get() as usize)
|
|
.sum()
|
|
}
|
|
});
|
|
let queue_courante: &ReadSignal<(usize, usize)> = create_memo(cx, {
|
|
move || {
|
|
let (idx, (id, _q)) = queues
|
|
.get()
|
|
.iter()
|
|
.map(|q| (q.id, *q.crédit_parole.get()))
|
|
.enumerate()
|
|
.max_by(|(_ia, (_id_a, a)), (_ib, (_id_b, b))| {
|
|
a.partial_cmp(b).expect("Tried to compare a NaN")
|
|
})
|
|
.expect("bla");
|
|
(idx, id)
|
|
}
|
|
});
|
|
provide_context_ref(cx, queue_courante);
|
|
let pause = move |_ev| {
|
|
courantə.set(None);
|
|
};
|
|
let next = move |_ev| {
|
|
//js_sys::alert("coucou");
|
|
let mut queue: ÉtatQueue = queues.get()[queue_courante.get().0].clone();
|
|
match queue.dequeue() {
|
|
Some(p) => {
|
|
courantə.set(Some(p));
|
|
date_prise_parole.set(js_sys::Date::now())
|
|
}
|
|
None => {
|
|
courantə.set(None);
|
|
}
|
|
}
|
|
for q in queues.get().iter() {
|
|
*q.crédit_parole.modify() +=
|
|
(*q.priorité.get() as f32) / (*total_priorités.get() as f32)
|
|
}
|
|
};
|
|
|
|
view! { cx,
|
|
div(on:keypress= {let next = next.clone();
|
|
move |ev: web_sys::Event| {
|
|
let event: web_sys::KeyboardEvent = ev.clone().unchecked_into();
|
|
if event.key_code() == 13 && event.ctrl_key() {
|
|
next(ev)
|
|
}
|
|
}
|
|
}) {
|
|
main {
|
|
h1 { "Kikikoz" }
|
|
p(class="orateurice") {
|
|
(
|
|
match &*courantə.get() {
|
|
None => "En attente".into(),
|
|
Some(p) => {
|
|
let temps_parole = std::time::Duration::from_millis((*heure_courante.get() - *date_prise_parole.get()) as u64).as_secs();
|
|
format!("On écoute {} depuis {:2}:{:02}", p, temps_parole / 60, temps_parole % 60)
|
|
|
|
}
|
|
}
|
|
)
|
|
br {}
|
|
button(on:click=next, id="bouton_next") { "Suivantə" }
|
|
" "
|
|
button(on:click=pause) { "Pause" }
|
|
}
|
|
section(id="les_queues") {
|
|
Keyed {
|
|
iterable: queues, //_pour_aff,
|
|
key: |q| q.id,
|
|
view: {
|
|
let queue_courante = queue_courante.clone();
|
|
move |cx, q|
|
|
view!{ cx,
|
|
div(class=(if queue_courante.get().1 == q.id {"active"} else {"attente"})) {
|
|
Queue {q}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
br {}
|
|
|
|
Explications {}
|
|
br {}
|
|
footer {
|
|
|
|
a(href="https://git.tausendblum.site/florent.becker/kikikoz") { "Voir le code source."}
|
|
}
|
|
|
|
}
|
|
BarreCôté {
|
|
queues
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
});
|
|
}
|