This commit is contained in:
Maieul BOYER 2026-02-06 23:24:21 +01:00
parent 8afdd208c6
commit 8e82ec9523
Signed by: maix
SSH key fingerprint: SHA256:iqCzqFFF5KjRixmDExqbAltCIj9ndlBWIGJf3t9Ln9g
23 changed files with 3345 additions and 0 deletions

2199
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
members = ["froxy-scraper", "froxy-templates"]
resolver = "3"

15
froxy-scraper/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "froxy-scrapper"
version = "0.1.0"
edition = "2024"
[dependencies]
constcat = "0.6.1"
reqwest = "0.13.1"
scraper = "0.25.0"
secrecy = "0.10.3"
smol_str = "0.3.5"
url = "2.5.8"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

View file

@ -0,0 +1,37 @@
use froxy_scrapper::{state::State, types::ClusterLocation};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let session = std::env::var("FROXY_COOKIE")
.expect("provide `FROXY_COOKIE` with the session cookie from you friends42 instance");
let state = State::new(session);
let res = state.get("/", Some("cluster=f4")).await.unwrap();
let text = res.text().await.unwrap();
let location = froxy_scrapper::parsing::get_cluster_location("F4", &text);
for l in &location.locations {
match &l.data {
froxy_scrapper::types::ClusterLocationData::Empty => {}
froxy_scrapper::types::ClusterLocationData::User {
login,
image,
relation,
} => {
eprintln!(
"[{:<10}] {login:<10} {relation:?} {}",
l.location,
image
.as_ref()
.map(|i| i.to_string())
.unwrap_or_else(|| "NO IMAGE".to_string())
)
}
froxy_scrapper::types::ClusterLocationData::Normal { status } => {
//eprintln!("[{:<10}] {status:?}", l.location)
}
}
}
}

5
froxy-scraper/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
#![allow(dead_code)]
pub mod types;
pub mod state;
pub mod parsing;

View file

@ -0,0 +1,121 @@
use scraper::Selector;
use url::Url;
use crate::types::{ClusterLocation, ClusterLocationData, Relation};
macro_rules! sel {
($name:ident, $value:literal) => {
static $name: std::sync::LazyLock<Selector> =
std::sync::LazyLock::new(|| Selector::parse($value).unwrap());
};
}
sel!(
LOCATION_SEL,
"div.container-fluid.mt-2.scroll.p-0 > table.grid.text-center > tbody > tr > td"
);
sel!(IMAGE_SEL, "img");
pub fn get_cluster_location(
cluster_name: impl AsRef<str>,
page: impl AsRef<str>,
) -> crate::types::CluserInformation {
let page = page.as_ref();
let doc = scraper::Html::parse_document(page);
let tds = doc.select(&LOCATION_SEL);
let mut out = crate::types::CluserInformation {
cluster_name: cluster_name.as_ref().into(),
locations: Vec::with_capacity(128),
};
for td in tds {
let pos_login = td.attr("data-pos").and_then(|pos| {
td.attr("data-login")
.map(|login| (pos.trim(), login.trim()))
});
let is_dead = td
.value()
.has_class("dead", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_warn = td
.value()
.has_class("attention", scraper::CaseSensitivity::AsciiCaseInsensitive);
match (pos_login, is_dead, is_warn) {
(Some((pos, login)), d, w) if login.is_empty() && (d || w) => {
out.locations.push(match (d, w) {
(true, _) => ClusterLocation {
location: pos.into(),
data: ClusterLocationData::Normal {
status: crate::types::ClusterLocationStatus::Dead,
},
},
(false, true) => ClusterLocation {
location: pos.into(),
data: ClusterLocationData::Normal {
status: crate::types::ClusterLocationStatus::Damaged,
},
},
// this should never happen
_ => ClusterLocation {
location: pos.into(),
data: ClusterLocationData::Empty,
},
})
}
(Some((pos, login)), _, _) if !login.trim().is_empty() => {
let image = td
.select(&IMAGE_SEL)
.next()
.and_then(|i| i.attr("src"))
.and_then(|s| Url::parse(s).ok());
let is_me = td
.value()
.has_class("me", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_focus = td
.value()
.has_class("focus", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_friend = td
.value()
.has_class("friend", scraper::CaseSensitivity::AsciiCaseInsensitive);
let is_close_friend = td.value().has_class(
"close_friend",
scraper::CaseSensitivity::AsciiCaseInsensitive,
);
let is_pooled = td
.value()
.has_class("pooled", scraper::CaseSensitivity::AsciiCaseInsensitive);
let mut relation = Relation::None;
if is_me {
relation = Relation::Me;
}
if is_friend {
relation = Relation::Friend;
}
if is_close_friend {
relation = Relation::CloseFriend;
}
if is_pooled {
relation = Relation::Pooled;
}
if is_focus {
relation = Relation::Focus;
}
out.locations.push(ClusterLocation {
location: pos.into(),
data: ClusterLocationData::User {
login: login.into(),
relation,
image,
},
})
}
(_, _, _) => {}
}
}
out
}

138
froxy-scraper/src/state.rs Normal file
View file

@ -0,0 +1,138 @@
use std::borrow::Cow;
use reqwest::{Method, Response};
use secrecy::{ExposeSecret, SecretString};
use url::UrlQuery;
macro_rules! const_from_env {
($const:ident, $env:literal) => {
const $const: Option<&'static str> = option_env!($env);
};
($const:ident, $env:literal, $default:expr) => {
const $const: &'static str = match option_env!($env) {
Some(s) => s,
None => $default,
};
};
}
const_from_env!(USER_AGENT_APP, "FROXY_UA_APP_NAME", "Froxy");
const_from_env!(USER_AGENT_VERSION, "FROXY_UA_APP_VERSION", {
const_from_env!(CARGO_PKG_VERSION, "CARGO_PKG_VERSION", "0.1-alpha");
CARGO_PKG_VERSION
});
const_from_env!(
USER_AGENT_DESC,
"FROXY_UA_DESC",
"(by maiboyer; for maiboyer)"
);
pub const DEFAULT_USER_AGENT: &str =
constcat::concat!(USER_AGENT_APP, "/", USER_AGENT_VERSION, USER_AGENT_DESC);
#[derive(Debug, Clone)]
pub struct State {
client: reqwest::Client,
base_url: url::Url,
secret_cookie: secrecy::SecretString,
user_agent: std::borrow::Cow<'static, str>,
}
impl State {
pub fn new(secret_cookie: impl AsRef<str>) -> Self {
let user_agent = DEFAULT_USER_AGENT;
let base_url = "https://friends.42paris.fr";
Self::with_info(
secret_cookie,
user_agent.into(),
url::Url::parse(base_url).expect("default base url not valid"),
)
}
pub fn with_info(
secret_cookie: impl AsRef<str>,
user_agent: std::borrow::Cow<'static, str>,
base_url: url::Url,
) -> Self {
let client = reqwest::Client::builder()
.user_agent(&*user_agent)
.build()
.unwrap();
Self {
client,
base_url,
secret_cookie: secret_cookie.as_ref().into(),
user_agent,
}
}
pub fn client(&self) -> &reqwest::Client {
&self.client
}
pub fn base_url(&self) -> &url::Url {
&self.base_url
}
pub fn cookie(&self) -> &SecretString {
&self.secret_cookie
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
}
impl State {
pub async fn request(
&self,
method: Method,
url: impl AsRef<str>,
qs: Option<&str>,
body: Option<Vec<u8>>,
) -> Result<Response, reqwest::Error> {
let mut u = self.base_url.clone();
u.set_path(url.as_ref());
u.set_query(qs);
let builder = self
.client
.request(method, u)
.header("Cookie", self.secret_cookie.expose_secret());
let builder = if let Some(body) = body {
builder.body(body)
} else {
builder
};
builder.send().await
}
pub async fn get(
&self,
url: impl AsRef<str>,
qs: Option<&str>,
) -> Result<Response, reqwest::Error> {
self.request(Method::GET, url.as_ref(), qs, None).await
}
pub async fn post(
&self,
url: impl AsRef<str>,
qs: Option<&str>,
) -> Result<Response, reqwest::Error> {
self.request(Method::POST, url.as_ref(), qs, None).await
}
pub async fn post_with_body(
&self,
url: impl AsRef<str>,
qs: Option<&str>,
body: Vec<u8>,
) -> Result<Response, reqwest::Error> {
self.request(Method::POST, url.as_ref(), qs, Some(body))
.await
}
}

View file

@ -0,0 +1,48 @@
#[non_exhaustive]
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub enum ClusterLocationStatus {
#[doc(alias = "red")]
Dead,
#[doc(alias = "orange")]
Damaged,
#[doc(alias = "good")]
#[default]
Ok,
}
#[derive(Debug, Clone, Default)]
pub enum Relation {
CloseFriend,
Friend,
Pooled,
Me,
Focus,
#[default]
None,
}
#[derive(Debug, Clone, Default)]
pub enum ClusterLocationData {
User {
login: smol_str::SmolStr,
image: Option<url::Url>,
relation: Relation,
},
Normal {
status: ClusterLocationStatus,
},
#[default]
Empty,
}
#[derive(Debug, Clone)]
pub struct ClusterLocation {
pub location: smol_str::SmolStr,
pub data: ClusterLocationData,
}
#[derive(Debug, Clone)]
pub struct CluserInformation {
pub cluster_name: smol_str::SmolStr,
pub locations: Vec<ClusterLocation>,
}

1
froxy-templates/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

View file

@ -0,0 +1,9 @@
[package]
name = "froxy-templates"
version = "0.1.0"
edition = "2024"
[dependencies]
minijinja = "2.15.1"
serde = { version = "1.0.228", features = ["derive"] }
smol_str = { version = "0.3.5", features = ["serde"] }

View file

@ -0,0 +1,20 @@
use froxy_templates::friends::{self, Friend};
fn main() {
let mut env = minijinja::Environment::new();
friends::add_to_context(&mut env);
let data = friends::render(
&env,
friends::FriendsData {
friends: std::iter::from_fn(|| Some(Friend {
name: "maiboyer".into(),
image: "https://friends.42paris.fr/proxy/resize/512/03c61af252becbca11aac5ff49a2e61c/maiboyer.jpg".into(),
position: Some("f1r1s1".into()),
last_active: None,
})).take(10).collect(),
},
)
.unwrap();
println!("{data}");
}

View file

@ -0,0 +1,30 @@
pub use crate::templates::FRIENDS;
pub fn add_to_context(env: &mut minijinja::Environment) {
if env.get_template("template.html").is_err() {
crate::meta_template::add_to_context(env);
}
if env.get_template("open_modal.html").is_err() {
crate::open_modal::add_to_context(env);
}
env.add_template("friends.html", FRIENDS).unwrap();
}
pub fn render(env: &minijinja::Environment, data: FriendsData) -> Result<String, minijinja::Error> {
let template = env.get_template("friends.html")?;
template.render(data)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Friend {
pub name: smol_str::SmolStr,
pub image: String,
pub position: Option<smol_str::SmolStr>,
pub last_active: Option<smol_str::SmolStr>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FriendsData {
pub friends: Vec<Friend>,
}

View file

@ -0,0 +1,11 @@
pub use crate::templates::INDEX;
pub fn add_to_context(env: &mut minijinja::Environment) {
if env.get_template("template.html").is_err() {
crate::meta_template::add_to_context(env);
}
if env.get_template("open_modal.html").is_err() {
crate::open_modal::add_to_context(env);
}
env.add_template("index.html", INDEX).unwrap();
}

View file

@ -0,0 +1,9 @@
#![allow(dead_code)]
mod templates;
pub mod friends;
pub mod meta_template;
pub mod open_modal;
pub mod profile;
pub mod index;

View file

@ -0,0 +1,5 @@
pub use crate::templates::TEMPLATE;
pub fn add_to_context(env: &mut minijinja::Environment) {
env.add_template("template.html", TEMPLATE).unwrap();
}

View file

@ -0,0 +1,5 @@
pub use crate::templates::OPEN_MODAL as TEMPLATE;
pub fn add_to_context(env: &mut minijinja::Environment) {
env.add_template("open_modal.html", TEMPLATE).unwrap();
}

View file

@ -0,0 +1,43 @@
use smol_str::SmolStr;
pub use crate::templates::PROFILE as TEMPLATE;
pub fn add_to_context(env: &mut minijinja::Environment) {
if env.get_template("template.html").is_err() {
crate::meta_template::add_to_context(env);
}
if env.get_template("open_modal.html").is_err() {
crate::open_modal::add_to_context(env);
}
env.add_template("profile.html", TEMPLATE).unwrap();
}
pub fn render(env: &minijinja::Environment, data: ProfileData) -> Result<String, minijinja::Error> {
let template = env.get_template("profile.html")?;
template.render(data)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProfileUser {
pub position: Option<SmolStr>,
pub last_active: SmolStr,
pub image: String,
pub name: SmolStr,
pub pool: Option<SmolStr>,
pub id: u64,
pub is_friend: bool,
pub is_self: bool,
pub github: Option<String>,
pub website: Option<String>,
pub discord: Option<String>,
/// profile description
pub recit: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProfileData {
pub user: ProfileUser,
}

View file

@ -0,0 +1,139 @@
{% extends 'template.html' %}
{% block css %}
<link href="/static/css/friends.css?v={{ version }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="modal fade" id="addFriendModal" tabindex="-1" aria-labelledby="addFriendLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="addFriendLabel">Ajouter un ami</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<span class="input-group-text" id="loginAddon">Login</span>
<input autofocus list="suggestions" type="text" class="form-control" id="addFriendInput"
aria-label="Login42" aria-describedby="loginAddon" placeholder="ami1, ami2, ami3">
<datalist id="suggestions"></datalist>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-primary" id="addFriendButton">
<i class="fa-solid fa-user-plus"></i> <i hidden class="spinner-border spinner-border-sm"></i>
Ajouter en ami
</button>
</div>
</div>
</div>
</div>
{% include 'open_modal.html' %}
<div class="container">
<div class="row text-center justify-content-center">
{% for friend in friends %}
<div id="case-{{ friend.name }}" class="card shadow m-2 pl-fix card-size grow"
onclick="openFriend('{{ friend.name }}', true)">
<img src="{{ friend.image | safe }}" class="m-1 card-img-top card-img-size"
alt="{{ friend.name }}'s image">
<div class="card-body">
<h5 class="card-title">
<i class="fa-solid {{ "fa-2xs fa-circle online" if friend.position else "fa-xs fa-person-walking offline" }}"></i> {{ friend.name }}
</h5>
<p class="card-text">{{ friend.position if friend.position else 'Absent' }} {{ friend.last_active }}
{% if friend.position %}
<a class="fa-solid fa-users-viewfinder" href="/goto/{{ friend.position }}"></a>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% if add %}
<div class="card p-0 m-2 shadow card-size grow" onclick="openAddFriend();">
<div class="m-1 w-100 card-img-top text-center card-not-img-size">
<i class="fa-solid fa-plus fa-5x"></i>
</div>
<div class="card-body">
<h5 class="card-title">Ajouter un ami</h5>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let openAddFriendModal;
let openFriendModal;
let addFriendInput = document.getElementById('addFriendInput');
let addFriendButton = document.getElementById('addFriendButton');
function fillSuggestions(element, suggestions) {
let list = document.querySelector(element);
list.innerHTML = ''
suggestions.forEach(function (item) {
if (item['type'] !== "user") return;
let option = document.createElement('option');
option.value = item['v'];
list.appendChild(option);
});
}
function openAddFriend() {
const modal = new bootstrap.Modal('#addFriendModal', {});
openAddFriendModal = modal;
modal.show();
setTimeout(() => {
addFriendInput.focus();
addFriendInput.select();
}, 500)
}
async function deleteLocalFriend() {
let friend_name = document.getElementById('openFriendLabel').innerText.trim();
let resp = await deleteFriend(friend_name, "#deleteFriend")
if (resp === 200) {
openFriendModal.hide();
document.getElementById('case-' + friend_name).remove();
}
}
addFriendInput.addEventListener('keyup', function (key) {
let name = addFriendInput.value.trim().toLowerCase();
if (key.key === 'Enter') {
if (name.length <= 3) {
addFriendInput.focus();
return;
}
addFriend(name, '#addFriendButton', true);
fillSuggestions('#suggestions', []);
}
if (name.trim() === '') return fillSuggestions('#suggestions', []);
if (name.length < 3) return;
setTimeout(() => {
if (name !== addFriendInput.value) return;
fetch('/search/' + encodeURIComponent(name) + "/1").then((response) => {
response.json().then((json) => {
fillSuggestions('#suggestions', json)
})
})
}, 200)
})
addFriendButton.addEventListener('click', function () {
let val = addFriendInput.value.trim().toLowerCase();
if (val.length <= 3) {
addFriendInput.focus();
return;
}
addFriend(val, '#addFriendButton', true)
})
</script>
{% endblock %}

View file

@ -0,0 +1,228 @@
{% extends 'template.html' %}
{% block css %}
<link href="/static/css/friends.css?v={{ version }}" rel="stylesheet">
<link href="/static/css/index.css?v={{ version }}" rel="stylesheet">
<style>
@media (max-width: 800px) {
.btn-width {
width: 100%;
}
}
.btn-width {
overflow-x: auto;
}
.range-no-color {
min-width: 0 !important;
}
.btn-check:checked+.btn-outline-warning > .text-muted {
color: black!important;
}
</style>
{% endblock %}
{% block content %}
{% include 'open_modal.html' %}
<div class="modal fade" id="issueModal" tabindex="-1" aria-labelledby="issueModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="issueModalLabel">Signalement dumps</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close report"></button>
</div>
<div class="modal-body">
Le dump <span id="dumpIdIssueModal"></span> ne fonctionne pas comme prévu ? Signalez le problème
pour avertir que la place n'est pas
libre
<div class="form-check" id="issueForm">
<input class="form-check-input" type="radio" data-id="1" name="issueRadios" id="issueRadios1">
<label class="form-check-label" for="issueRadios1">
Le dump est inutilisable
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" data-id="2" name="issueRadios" id="issueRadios2">
<label class="form-check-label" for="issueRadios2">
Le dump fonctionne, mais l'écran ou le clavier est dégradé
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="button" class="btn btn-primary" id="submitIssue">Valider</button>
</div>
</div>
</div>
</div>
<div class="text-center">
<div class="btn-group mb-1 btn-width" role="group" aria-label="Change cluster">
{% for cluster in clusters %}
<input type="radio" class="btn-check cluster_radios" name="btn-radio"
data-cluster="{{ cluster.name }}" id="radio{{ cluster.name }}"
autocomplete="off" {{ "checked" if actual_cluster == cluster['name'] else "" }}>
<label title="{{ (int(cluster.users / (cluster.maximum_places - cluster.dead_pc) * 100) if (cluster.maximum_places - cluster.dead_pc) > 0 else 0 ) }}%" class="btn btn-outline-{{ place_to_btn(cluster) }} m-t" for="radio{{ cluster.name }}">
{{ ('<i class="fa-solid fa-person-swimming text-info"></i>' if cluster.name in piscine else '') | safe }}{{ (' ' if cluster.name in piscine and cluster.name in silent else '') | safe }}{{ ('<i class="fa-solid fa-volume-xmark text-secondary"></i> ' if cluster.name in silent else '') | safe }}{{ cluster.name }}
<span class="text-muted">{{ cluster.users }}/{{ cluster.maximum_places - cluster.dead_pc }}</span></label>
{%- endfor -%}
</div>
</div>
<div class="container-fluid mt-2 scroll p-0">
<table class="grid text-center">
<tbody>
{% for index, x in enumerate(map[1:], 0) %}
<tr>
{%- set countB = namespace(value=1) -%}
<td class="range">{{ map[0][index] }}</td>
{%- for y in x -%}
{%- if y == 'x' -%}
<td class="range">&nbsp;</td>
{%- elif y == '|' -%}
<td class="range-no-color"></td>
{%- elif y == 'h' -%}
<td></td>
{%- elif y == 'd' -%}
<td><i class="fa-solid fa-person-walking fa-2xl"></i></td>
{%- elif y == 'f' -%}
<td><i class="fa-solid fa-laptop fa-xl"></i></td>
{%- elif exrypz(y) == False -%}
<td class="small-colors"></td>
{%- else -%}
{%- set text_color = '' -%}
{%- set img = '<i class="fa-solid fa-user"></i>' -%}
{%- if y in locations -%}
{%- set img = '<img loading="lazy" class="profile-pic2" alt="" src="' + (custom_image(locations[y]['user']['login']) if locations[y]['has_custom_image'] else proxy_images(locations[y]['user']['image']['versions']['small'])) + '">' -%}
{%- elif actual_cluster in piscine and y in tutor_station -%}
{%- set img = '<i class="fa-solid fa-shield tutor-shield"></i>' -%}
{%- endif -%}
{%- if actual_cluster in piscine and y in tutor_station -%}
{%- set text_color = 'tutor-shield' -%}
{%- endif -%}
{%- set relation = '' -%}
{%- set currently_in_piscine = y in locations and (locations[y]['user']['pool_month'], locations[y]['user']['pool_year']) in piscine_date -%}
{%- set in_piscine_cluster = actual_cluster in piscine -%}
{%- if y in locations and locations[y]['user']['id'] not in tutors and currently_in_piscine != in_piscine_cluster -%}
{%- set relation = 'wrong-cluster' -%}
{%- endif -%}
{%- if focus and focus == y %}
{%- set relation = 'focus' -%}
{%- elif y in locations and locations[y]['me'] -%}
{%- set relation = 'me' -%}
{%- elif y in locations and locations[y]['whitelist'] -%}
{%- set relation = 'whitelist' -%}
{%- elif y in locations and locations[y]['close_friend'] -%}
{%- set relation = 'close_friend' -%}
{%- elif y in locations and locations[y]['friend'] -%}
{%- set relation = 'friend' -%}
{%- elif y in issues_map and issues_map[y]['count'] >= 1 and issues_map[y]['issue'] == 1 -%}
{%- set relation = 'dead' -%}
{%- elif y in issues_map and issues_map[y]['count'] >= 1 and issues_map[y]['issue'] == 2 -%}
{%- set relation = 'attention' -%}
{%- elif y in locations and locations[y]['pool'] and actual_cluster not in piscine -%}
{%- set relation = 'pooled' -%}
{%- endif -%}
{%- if actual_cluster in piscine and y in locations and locations[y]['user']['id'] in tutors -%}
{%- set relation = 'tutor' -%}
{%- set text_color = '' -%}
{%- elif actual_cluster in piscine and y in locations and not locations[y]['user']['tutor'] in tutors and y in tutor_station -%}
{%- set relation = 'tutor-spot' -%}
{%- set text_color = '' -%}
{%- endif -%}
{%- if y in locations and locations[y]['admin'] -%}
{%- set relation = 'admin' -%}
{%- endif -%}
{%- if countB.value % 2 == 0 -%}
<td data-pos="{{ y }}"
data-login="{{ locations[y]['user']['login'] if y in locations else '' }}"
class="sm-t case {{ relation }} {{ text_color }}">{{ img|safe }}<br>{{ y.replace(actual_cluster, '') }}
</td>
{%- else -%}
<td data-pos="{{ y }}"
data-login="{{ locations[y]['user']['login'] if y in locations else '' }}"
class="sm-t case {{ relation }} {{ text_color }}">{{ y.replace(actual_cluster, '') }}<br>{{ img|safe }}
</td>
{%- endif -%}
{%- endif -%}
{%- set countB.value = countB.value + 1 -%}
{%- endfor -%}
<td class="range">{{ map[0][index] }}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
<div id="tooltip" hidden class="rounded bg-white zindex9999">
<img class="profile-pic rounded no-shadow" alt="pp">
<div class="fluid-container text-center text-black name">name</div>
</div>
</div>
<script src="/static/js/popper.min.js?v={{ version }}" defer></script>
{% endblock %}
{% block scripts %}
<script>
let tooltip = document.getElementById('tooltip')
let cases = document.querySelectorAll('.case');
cases.forEach(e => {
e.addEventListener('click', () => {
let login = e.dataset.login
let dump = e.dataset.pos
if (login.length === 0) {
const modal = new bootstrap.Modal('#issueModal', {});
document.getElementById('submitIssue').onclick = async function () {
let checked = document.querySelector('input[name="issueRadios"]:checked');
if (!checked) {
document.getElementById('issueRadios2').focus()
return;
}
let resp = checked.dataset.id;
fetch(`/addissue/${dump}/${resp}`).then(resp => {
if (resp.status === 200) {
triggerToast('Merci de votre signalement ! :)', true, false);
modal.hide();
} else {
triggerToast("Une erreur s'est produite lors du signalement", false, false);
}
})
}
document.getElementById('dumpIdIssueModal').innerText = dump;
modal.show();
return;
}
openFriend(login, true)
})
if (!e.dataset.login)
return;
e.addEventListener('mouseover', () => {
if (window.isTouchDevice && isTouchDevice()) return;
if (!window.Popper) return;
tooltip.hidden = false;
tooltip.querySelector('img').src = e.querySelector('img').src
tooltip.querySelector('.name').innerText = e.dataset.login
Popper.createPopper(e, tooltip, {
placement: 'right'
})
})
e.addEventListener('mouseleave', () => {
tooltip.hidden = true;
});
});
// Change cluster buttons
let radio_clusters = document.querySelectorAll('.cluster_radios')
radio_clusters.forEach(e => {
e.addEventListener('click', () => {
let cluster = e.dataset.cluster
location.href = "/?cluster=" + cluster
})
})
</script>
{% endblock %}

View file

@ -0,0 +1,11 @@
macro_rules! template {
($name:ident, $path:literal) => {
pub static $name: &'static str = include_str!($path);
};
}
template!(FRIENDS, "./friends.html");
template!(INDEX, "./index.html");
template!(OPEN_MODAL, "./open_modal.html");
template!(PROFILE, "./profile.html");
template!(TEMPLATE, "./template.html");

View file

@ -0,0 +1,76 @@
<div class="modal fade" id="openFriendModal" tabindex="-1" aria-labelledby="openFriendLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5 me-2" id="openFriendLabel"></h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- PP - Name - Pool -->
<div class="modal-body">
<div class="mb-3 text-center">
<img alt="" class="rounded-pill profile-pic" style="height:9rem" id="openFriendModalPic" src="">
<div class="display-inline-grid ms-3">
<span id="openFriendModalName">
<h5 class="name display-5 fw-bold"></h5>
<span id="addCloseFriend">
<i class="fa-regular fa-star text-warning"></i>
<i hidden class="spinner-border spinner-border-sm"></i>
</span>
<span id="removeCloseFriend">
<i class="fa-solid fa-star text-warning"></i>
<i hidden class="spinner-border spinner-border-sm"></i>
</span>
<span class="fs-6 text-muted pool">Piscine de</span>
</span>
</div>
</div>
<!-- Bio & Socials -->
<div hidden class="rounded bg-dark-subtle m-2 p-1" id="FP-bio">
<div hidden class="row text-center" id="FP-socials-row">
<div hidden class="col no-wrap" id="FP-github">
<i class="fa-brands fa-github icon-length"></i>
<a class="{{ 'no-click' if kiosk else '' }}" href=""></a>
</div>
<div hidden class="col no-wrap" id="FP-website">
<i class="fa-solid fa-globe icon-length"></i>
<a class="{{ 'no-click' if kiosk else '' }}" href=""></a>
</div>
<div hidden class="col no-wrap" id="FP-discord">
<i class="fa-brands fa-discord icon-length"></i> <a></a>
</div>
</div>
<div hidden class="div text-center m-1 justify" style="text-align: justify" id="FP-text"></div>
</div>
<!-- Buttons -->
<div class="row text-center">
<div class="col-md mb-1">
<button type="button" class="btn btn-sm btn-primary" id="openFriendShowCluster">
<i class="fa-solid fa-magnifying-glass"></i> Afficher dans le Cluster
</button>
</div>
<div class="col-md mb-1">
<a id="openFriendProfile">
<button type="button" class="btn btn-sm btn-secondary">
<i class="fa-solid fa-circle-info"></i> Voir l'intra de l'utilisateur
</button>
</a>
</div>
<div class="col-md mb-1">
<button hidden type="button" class="btn btn-sm btn-danger" id="openFriendLabelDeleteFriend">
<i class="fa-solid fa-user-minus"></i>
<i hidden class="spinner-border spinner-border-sm"></i>
Supprimer des contacts
</button>
<button type="button" class="btn btn-sm btn-success" id="openFriendLabelAddFriend">
<i class="fa-solid fa-user-plus"></i><i hidden class="spinner-border spinner-border-sm"></i>
Ajouter au contacts
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,111 @@
{% extends 'template.html' %}
{% block css %}
<style>
.align-vertical {
vertical-align: middle;
}
.badge-sm {
font-size: 1rem;
}
</style>
<link href="/static/css/friends.css?v={{ version }}" rel="stylesheet">
<link href="/static/css/profile.css?v={{ version }}" rel="stylesheet">
{% endblock %}
{% block content %}
{% include 'open_modal.html' %}
<div class="container mt-2">
<div class="p-5 mb-4 bg-light-subtle rounded-3 shadow-sm">
<div class="container-fluid py-5">
<div class="mb-4 text-center">
<img class="rounded-pill profile-pic" style="height:9rem" src="{{ user.image }}" alt="{{ user.name }}'s pic">
<div class="display-inline-grid ms-3">
<h1 class="display-5 fw-bold">
{{ user.name }}
{% if 'None' not in user.pool %}<span class="fs-6 text-muted ">Piscine de {{ user.pool }}</span>{% endif %}
</h1>
<span class="text-left">
<i class="fa-solid {{ "fa-2=xs fa-circle online" if user.position else "fa-xs fa-person-walking offline" }}"></i>
{{ user.position if user.position else 'Absent' }} {{ user.last_active }}
{% if user.position %}<a class="fa-solid fa-users-viewfinder" href="/goto/{{ user.position }}"></a>{% endif %}
</span>
<span class="text-left">
{% if !user.is_self %}
<button {{ 'hidden' if user.is_friend else '' }}
type="button"
class="btn btn-sm btn-outline-success online"
id="addLocalFriend">
<i class="fa-solid fa-user-plus"></i>
<i hidden class="spinner-border spinner-border-sm"></i>
Ajouter {{ user.name }} en ami
</button>
<button {{ 'hidden' if not user.is_friend else '' }}
type="button"
class="btn btn-sm btn-outline-danger"
id="removeLocalFriend">
<i class="fa-solid fa-user-minus"></i>
<i hidden class="spinner-border spinner-border-sm"></i>
Retirer {{ user.name }} des amis
</button>
{% endif %}
<button type="button"
onclick="newTab('https://profile.intra.42.fr/users/{{ user.name }}');"
class="btn btn-sm btn-outline-secondary">
<i class="fa-solid fa-circle-info"></i>
Profil intra de {{ user.name }}
</button>
</span>
</div>
</div>
<div class="fs-5">
<div class="row">
<div class="col-md-3">
{%- if user.github or user.website or user.discord -%}
<p class="mb-0">Contacts</p>
<ul>
{%- if 'github' in user and len(user.github) >= 1 -%}
<li>
<i class="fa-brands fa-github icon-length"></i> <a href="{{ user.github }}">{{ user.github.replace('https://github.com/', '') }}</a>
</li>
{%- endif -%}
{%- if 'website' in user and len(user.website) >= 1 -%}
<li>
<i class="fa-solid fa-globe icon-length"></i> <a href="{{ user.website }}">{{ user.website.replace('https://', '').replace("http://", "") }}</a>
</li>
{%- endif -%}
{%- if 'discord' and len(user.discord) >= 1 -%}
<li>
<i class="fa-brands fa-discord icon-length"></i> <a>{{ user.discord }}</a>
</li>
{%- endif -%}
</ul>
{% endif %}
</div>
<div class="col-md-8" style="text-align: justify;">{{ user.recit if user.recit and len(user.recit) > 0 else '' }}</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let addLocalFriend = document.getElementById('addLocalFriend');
let removeLocalFriend = document.getElementById('removeLocalFriend');
if (addLocalFriend)
addLocalFriend.addEventListener('click', async () => {
await addFriend('{{ user.name }}', '#addLocalFriend');
addLocalFriend.hidden = true;
removeLocalFriend.hidden = false;
})
if (removeLocalFriend)
removeLocalFriend.addEventListener('click', async () => {
await deleteFriend('{{ user.name }}', '#removeLocalFriend');
addLocalFriend.hidden = false;
removeLocalFriend.hidden = true;
})
</script>
{% endblock %}

View file

@ -0,0 +1,81 @@
{%- set version='9' %}
<!doctype html>
<html lang="fr" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FFT - Maix.me</title>
<link href="/static/css/bootstrap.min.css?v={{ version }}" rel="stylesheet">
<link href="/static/css/common.css?v={{ version }}" rel="stylesheet">
<link rel="manifest" href="/static/manifest.json?v={{ version }}">
<link rel="shortcut icon" type="image/x-icon" href="/static/img/favicon.ico">
<link rel="apple-touch-icon" href="/static/img/apple-touch-icon.png"/>
<meta name="theme-color" content="rgb(43, 48, 53)"/>
{% block css %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary mb-2 shadow">
<div class="container-fluid">
<a class="navbar-brand me-2 position-relative " href="/">
<img src="/static/img/android-chrome-192x192.png" alt="Logo" width="24" height="24" class="d-inline-block align-text-top">
FFT<span class="text-muted text-beta"></span>
</a>
<button class="btn btn-secondary hide-navbar" hidden id="qc-friends">
<i class="fa-solid fa-user-group"></i>
</button>
<button class="btn btn-secondary hide-navbar" hidden id="qc-cluster">
<i class="fa-solid fa-layer-group"></i>
</button>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Clusters</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/friends/">Amis</a>
</li>
</ul>
<div class="d-flex" role="search">
<button type="button" class="btn btn-outline-secondary me-1" onclick="newTab('https://github.com/maix0/fft');" aria-label="Github">
<i class="fa-brands fa-github"></i> <i hidden class="spinner-border spinner-border-sm"></i>
</button>
<input class="form-control" list="global_suggestions" id="globalSearch" type="search" placeholder="Rechercher..." aria-label="Search">
<datalist id="global_suggestions"></datalist>
<button type="button" id="globalSearchButton" aria-label="Search" class="btn btn-outline-light mx-1">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
<button type="button" class="btn btn-outline-light" aria-label="Settings"
onclick="location.href = '/settings/';">
<i class="fa-solid fa-gear"></i>
</button>
</div>
</div>
</div>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div id="liveToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive"
aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="toast_body">
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
</div>
{% block content %}{% endblock %}
<script async src="/static/js/bootstrap.min.js?v={{ version }}"></script>
<script src="/static/js/common.js?v={{ version }}"></script>
{% block scripts %}{% endblock %}
<link href="/static/fontawesome/css/fontawesome.min.css?v={{ version }}" rel="stylesheet">
<link href="/static/fontawesome/css/solid.min.css?v={{ version }}" rel="stylesheet">
<link href="/static/fontawesome/css/regular.min.css?v={{ version }}" rel="stylesheet">
<link href="/static/fontawesome/css/brands.min.css?v={{ version }}" rel="stylesheet">
</body>
</html>