Merge pull request 'leptos' (#2) from leptos into master

Reviewed-on: https://git.int.philt3r.eu/phil/plcom/pulls/2
This commit is contained in:
phil 2024-05-02 19:47:46 +02:00
commit 531604a491
83 changed files with 4625 additions and 2961 deletions

View file

@ -1,11 +1,10 @@
/public/style.css target/
wallpapers.json
/target/ .idea/
.vscode/
/.idea/
/.vscode/
.DS_Store .DS_Store
.gitea/ .gitea/
/readme.md readme.md
/Dockerfile Dockerfile
/.dockerignore .dockerignore

13
.editorconfig Normal file
View file

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
[*.js]
indent_style = space
indent_size = 2
[*.json]
indent_style = space
indent_size = 4

View file

@ -1,5 +1,8 @@
name: Build name: Build
on: [push] on:
push:
branches:
- master
jobs: jobs:
build-docker: build-docker:

14
.gitignore vendored
View file

@ -1,11 +1,12 @@
# wallpapers
/src/wallpapers.rs
# built css # built css
/public/style.css /public/style.css
# wallpapers
wallpapers.json
# build output # build output
/target target/
pkg
# environment variables # environment variables
.env .env
@ -15,4 +16,7 @@
.DS_Store .DS_Store
# ide # ide
.idea/ .idea/
# rustfmt
**/*.rs.bk

2656
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,15 @@
[package] [workspace]
name = "plcom" resolver = "2"
version = "0.1.0" members = ["crates/gen-wallpapers", "crates/plcom"]
edition = "2021"
publish = false
default-run = "plcom"
[[bin]] [workspace.dependencies]
name = "plcom" serde = "1.0"
path = "src/main.rs"
[[bin]] # Defines a size-optimized profile for the WASM bundle in release mode
name = "gen-wallpapers" [profile.wasm-release]
path = "src/gen-wallpapers.rs" inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[dependencies]
rocket = "0.5"
rocket_async_compression = "0.5"
askama = { version = "0.12.1", features = ["with-rocket"] }
askama_rocket = "0.12.0"
chrono = "0.4.31"
minify-html = "0.11.1"
# wallpapers
nanorand = { version = "0.7.0", features = ["chacha"] }
kamadak-exif = "0.5.5"
reqwest = { version = "0.11.22", features = ["blocking", "json"] }
serde_json = "1.0.108"
dms-coordinates = "1.1.0"

View file

@ -1,39 +1,51 @@
ARG RUST_VERSION=1.74.0 ARG RUST_VERSION=1.77
FROM rust:${RUST_VERSION}-bookworm as builder
FROM rust:${RUST_VERSION}-slim-bookworm as builder # Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin
# tailwind # Install cargo-leptos
WORKDIR /usr/bin/ RUN cargo binstall cargo-leptos -y
ADD https://github.com/tailwindlabs/tailwindcss/releases/download/v3.3.5/tailwindcss-linux-x64 tailwindcss
RUN chmod +x /usr/bin/tailwindcss
# openssl + CA certs # Add the WASM target
RUN apt-get update; \ RUN rustup target add wasm32-unknown-unknown
apt-get install -y --no-install-recommends ca-certificates pkg-config libssl-dev
WORKDIR /usr/src/plcom # Make an /app dir, which everything will eventually live in
COPY css/ css/ RUN mkdir -p /app
COPY public/ public/ WORKDIR /app
COPY templates/ templates/ COPY . .
COPY src/ src/
COPY build.rs .
COPY Cargo.lock .
COPY Cargo.toml .
COPY tailwind.config.cjs .
# generate wallpapers # Generate wallpapers metadata
RUN cargo build --bin gen-wallpapers --release RUN cargo run -p gen-wallpapers --example cli -- ./public/wallpapers > crates/plcom/wallpapers.json
RUN cargo run --bin gen-wallpapers --release
# build project # Build the app
RUN cargo build --bin plcom --release RUN cargo leptos build --release -vv
FROM debian:12-slim FROM debian:bookworm-slim as runtime
WORKDIR /usr/share/plcom WORKDIR /app
COPY --from=builder /usr/src/plcom/public /usr/share/plcom/public RUN apt-get update -y \
COPY --from=builder /usr/src/plcom/target/release/plcom /usr/share/plcom && apt-get install -y --no-install-recommends openssl ca-certificates \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
ENV ROCKET_CLI_COLORS=0 # Copy the server binary to the /app directory
ENV ROCKET_ADDRESS=0.0.0.0 COPY --from=builder /app/target/release/plcom /app/
# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/site
# Copy Cargo.toml if its needed at runtime
COPY --from=builder /app/Cargo.toml /app/
# Set any required env variables and
ENV RUST_LOG="info"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8000"
ENV LEPTOS_SITE_ROOT="site"
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/usr/share/plcom/plcom"]
# Run the server
CMD ["/app/plcom"]

View file

@ -1,21 +0,0 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=tailwind.config.cjs");
println!("cargo:rerun-if-changed=templates/");
let result = std::process::Command::new("tailwindcss")
.arg("-i")
.arg("./css/tailwind.css")
.arg("-o")
.arg("./public/style.css")
.output()
.unwrap();
println!("{:?}", result);
if !result.status.success() {
panic!("Failed to run tailwindcss")
}
Ok(())
}

View file

@ -0,0 +1,14 @@
[package]
name = "gen-wallpapers"
version = "0.1.0"
edition = "2021"
[dependencies]
kamadak-exif = "0.5.5"
reqwest = { version = "0.11.22", features = ["blocking", "json"] }
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.108"
dms-coordinates = "1.3.0"
[dev-dependencies]
clap = { version = "4.5.4", features = ["derive"] }

View file

@ -0,0 +1,31 @@
use clap::Parser;
use gen_wallpapers::MetadataList;
use std::fs::read_dir;
use std::path::PathBuf;
#[derive(Parser)]
struct Cli {
folder: PathBuf,
}
fn main() {
let cli = Cli::parse();
let dir = match read_dir(cli.folder) {
Ok(dir) => dir,
Err(e) => {
eprintln!("failed to read folder: {e}");
return;
}
};
let metadata = MetadataList::process_folder(dir, true);
let json = match metadata.to_pretty_json() {
Ok(json) => json,
Err(e) => {
eprintln!("failed to serialize json: {e}");
return;
}
};
println!("{json}");
}

View file

@ -0,0 +1,222 @@
use exif::{DateTime, Exif, In, Tag};
use serde::Serialize;
use std::fs::ReadDir;
use std::io::BufReader;
fn parse_coordinates(exif: &Exif, tag: Tag, r#ref: Tag) -> Option<f32> {
let mut coord = None;
let mut coord_ref = None;
// Parse DMS coordinates
if let Some(field) = exif.get_field(tag, In::PRIMARY) {
match field.value {
exif::Value::Rational(ref vec) if !vec.is_empty() => {
let deg = vec[0].to_f64() as u16;
let min = vec[1].to_f64() as u8;
let sec = vec[2].to_f64();
coord = Some((deg, min, sec));
}
_ => {}
}
}
// Get bearing
if let Some(field) = exif.get_field(r#ref, In::PRIMARY) {
coord_ref = Some(field.display_value().to_string());
}
match (coord, coord_ref) {
(Some((deg, min, sec)), Some(r#ref)) => {
use dms_coordinates::Cardinal;
use dms_coordinates::Cardinal::*;
let bearing: Option<Cardinal> = match r#ref.as_str() {
"N" => Some(North),
"NE" => Some(NorthEast),
"NW" => Some(NorthWest),
"S" => Some(South),
"SE" => Some(SouthEast),
"SW" => Some(SouthWest),
"E" => Some(East),
"W" => Some(West),
_ => None,
};
let dms = dms_coordinates::DMS::new(deg, min, sec, bearing);
Some(dms.to_ddeg_angle() as f32)
}
(_, _) => None,
}
}
#[derive(Debug, Serialize)]
struct Metadata {
filename: String,
date: String,
width: u32,
height: u32,
gps: Gps,
location: Option<Location>,
}
#[derive(Debug, Serialize)]
pub struct Location {
precise: String,
broad: String,
}
#[derive(Debug, Serialize)]
pub struct Gps {
latitude: f32,
longitude: f32,
}
#[derive(Debug, Serialize)]
pub struct MetadataList(Vec<Metadata>);
impl MetadataList {
pub fn process_folder(dir: ReadDir, get_location: bool) -> Self {
let mut files = vec![];
for file in dir {
let Ok(file) = file else {
continue;
};
// Get filename
let Ok(filename) = file.file_name().into_string() else {
continue;
};
// Read exif from file
let Ok(file) = std::fs::File::open(file.path()) else {
continue;
};
let mut reader = BufReader::new(file);
let Ok(exif) = exif::Reader::new().read_from_container(&mut reader) else {
continue;
};
eprintln!("Processing `{}`", filename);
// Get GPS coordinates
let latitude = parse_coordinates(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
let longitude = parse_coordinates(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
// Get date
let mut date = None;
if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) {
match field.value {
exif::Value::Ascii(ref vec) if !vec.is_empty() => {
if let Ok(datetime) = DateTime::from_ascii(&vec[0]) {
let datetime = datetime.to_string();
let split: Vec<&str> = datetime.split(' ').collect();
date = split.first().map(|str| str.to_string());
}
}
_ => {}
}
}
// Get image width
let mut width = None;
if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) {
if let Some(exif_width) = field.value.get_uint(0) {
width = Some(exif_width);
}
}
// Get image height
let mut height = None;
if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) {
if let Some(exif_height) = field.value.get_uint(0) {
height = Some(exif_height);
}
}
match (date, latitude, longitude, width, height) {
(Some(date), Some(latitude), Some(longitude), Some(width), Some(height)) => {
let gps = Gps {
latitude,
longitude,
};
let location = if get_location {
eprintln!("Getting location for `{}`", filename);
gps.get_location()
} else {
None
};
files.push(Metadata {
filename,
width,
height,
date,
gps,
location,
});
}
_ => {
continue;
}
}
}
Self(files)
}
pub fn to_pretty_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(&self.0)
}
}
impl Gps {
fn get_location(&self) -> Option<Location> {
match reqwest::blocking::Client::new()
.get(format!(
// Documentation: https://nominatim.org/release-docs/develop/api/Reverse/
"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={}&lon={}&zoom=8",
self.latitude, self.longitude,
))
.header("User-Agent", "https://philippeloctaux.com")
.send()
.and_then(|data| data.json::<serde_json::Value>())
{
Ok(data) => {
let location = &data["display_name"];
if location.is_string() {
let location = location.to_string();
// Remove first and last characters (the string is wrapped in double quotes '"')
let location = {
let mut chars = location.chars();
chars.next();
chars.next_back();
chars.as_str()
};
eprintln!("Raw location is `{}`", location);
let mut location = location.split(',');
let precise = location.next().unwrap_or("?").to_string();
let mut broad: String =
location.collect::<Vec<&str>>().join(",").trim().to_string();
if broad.is_empty() {
broad.push('?');
}
let location = Location { precise, broad };
eprintln!("Location is `{:?}`", location);
Some(location)
} else {
eprintln!("Failed to find location.");
None
}
}
Err(_) => {
eprintln!("Failed to make API call to get location.");
None
}
}
}
}

1
crates/plcom/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
css/tailwind-output.css

118
crates/plcom/Cargo.toml Normal file
View file

@ -0,0 +1,118 @@
[package]
name = "plcom"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# leptos + axum
axum = { version = "0.7", optional = true }
console_error_panic_hook = "0.1"
leptos = { version = "0.6" }
leptos_axum = { version = "0.6", optional = true }
leptos_meta = { version = "0.6" }
leptos_router = { version = "0.6" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
wasm-bindgen = "=0.2.92"
thiserror = "1"
tracing = { version = "0.1", optional = true }
http = "1"
# external crates
tailwind_fuse = { version = "0.2.0", features = ["variant"] }
chrono = "0.4.37"
getrandom = { version = "0.2", features = ["js"] }
rand = "0.8.5"
leptos-leaflet = "0.8.0"
[build-dependencies]
serde = { workspace = true }
serde_json = "1.0"
gen-wallpapers = { path = "../../crates/gen-wallpapers" }
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate", "leptos-leaflet/hydrate"]
ssr = [
"dep:axum",
"dep:tokio",
"dep:tower",
"dep:tower-http",
"dep:leptos_axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:tracing",
"leptos-leaflet/ssr"
]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "plcom"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# The tailwind input file.
#
# Optional, Activates the tailwind build
tailwind-input-file = "css/main.css"
# The tailwind config file.
#
# Optional, defaults to "tailwind.config.js" which if is not present
# is generated for you
tailwind-config-file = "tailwind.config.js"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "../../public"
# Additional files triggering recompilation
watch-additional-files = ["resume.json", "wallpapers.json"]
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "0.0.0.0:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"

365
crates/plcom/build.rs Normal file
View file

@ -0,0 +1,365 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Work {
name: String,
position: String,
start_date: String,
end_date: Option<String>,
logo: Logo,
description: String,
highlights: Vec<String>,
technologies: Vec<String>,
link: Option<Link>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Project {
name: String,
description: String,
start_date: String,
end_date: Option<String>,
presentation: Vec<String>,
highlights: Vec<String>,
keywords: Vec<String>,
link: Option<Link>,
logo: Option<Logo>,
image: Option<Image>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Image {
file: String,
// TODO: use enum directly instead of string
// position: ImagePosition,
position: String,
}
#[derive(Debug, Deserialize)]
// TODO: possible implementation: https://www.reddit.com/r/rust/comments/10bab4v/serdejson_how_to_deserialize_and_serialize_an/
enum ImagePosition {
Left,
Right,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Link {
uri: String,
label: String,
not_available: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Logo {
file: String,
transparent_background: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Education {
institution: String,
study_type: String,
area: String,
start_date: String,
end_date: Option<String>,
logo: Option<Logo>,
courses: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Wallpaper {
filename: String,
date: String,
gps: Gps,
location: Location,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Gps {
latitude: f32,
longitude: f32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Location {
precise: String,
broad: String,
}
fn vec_strings(vec: &Vec<String>) -> String {
let mut string = String::new();
string.push('[');
for el in vec {
string.push_str(&format!("{:?},", el));
}
string.push(']');
string
}
fn resume(dest: &std::path::Path, source: std::fs::File) {
let reader = std::io::BufReader::new(source);
let data: serde_json::Value = serde_json::from_reader(reader).expect("Failed to parse JSON");
let mut final_ser = String::new();
// Work
let work_json = &data["work"];
let work_data: Vec<Work> =
serde_json::from_value(work_json.clone()).expect("Failed to parse work");
let mut work_str = String::new();
work_str.push('[');
for work in &work_data {
work_str.push_str("Work {");
work_str.push_str(&format!("name: {:?},", work.name));
work_str.push_str(&format!("position: {:?},", work.position));
work_str.push_str(&format!("start_date: {:?},", work.start_date));
work_str.push_str(&format!(
"end_date: {},",
match &work.end_date {
Some(end) => format!("Some({:?})", end),
None => "None".into(),
}
));
// Optional struct
// TODO: refacto
match &work.link {
// TODO: enum with 2 variants for unavailable link
Some(link) => match link.not_available {
true => work_str.push_str(&format!(
"link: Some(ResumeLink {{ uri: {:?}, label: \"Not available\", not_available: true }}),",
link.uri
)),
false => work_str.push_str(&format!(
"link: Some(ResumeLink {{ uri: {:?}, label: {:?}, not_available: false }}),",
link.uri, link.label
)),
},
None => work_str.push_str("link: None,"),
}
// Struct
work_str.push_str(&format!(
"logo: Logo {{ file: {:?}, transparent_background: {} }},",
work.logo.file, work.logo.transparent_background
));
work_str.push_str(&format!("description: {:?},", work.description));
// Vector
let mut highlights = String::new();
highlights.push('[');
for high in &work.highlights {
highlights.push_str(&format!("{:?},", high));
}
highlights.push(']');
work_str.push_str(&format!("highlights: &{},", highlights));
// Vector
let mut technologies = String::new();
technologies.push('[');
for tech in &work.technologies {
technologies.push_str(&format!("{:?},", tech));
}
technologies.push(']');
work_str.push_str(&format!("technologies: &{},", technologies));
work_str.push_str("},\n");
}
work_str.push(']');
let work_ser = format!(
"pub const WORK: [Work; {}] = \n{};\n",
work_data.len(),
work_str
);
final_ser.push_str(&work_ser);
// Projects
let projects_json = &data["projects"];
let projects_data: Vec<Project> =
serde_json::from_value(projects_json.clone()).expect("Failed to parse projects");
let mut projects_str = String::new();
projects_str.push('[');
for project in &projects_data {
projects_str.push_str("Project {");
projects_str.push_str(&format!("name: {:?},", project.name));
projects_str.push_str(&format!("description: {:?},", project.description));
projects_str.push_str(&format!("start_date: {:?},", project.start_date));
projects_str.push_str(&format!(
"end_date: {},",
match &project.end_date {
Some(end) => format!("Some({:?})", end),
None => "None".into(),
}
));
// Optional struct
match &project.link {
// TODO: enum with 2 variants for unavailable link
Some(link) => match link.not_available {
true => projects_str.push_str(&format!(
"link: Some(ResumeLink {{ uri: {:?}, label: \"Not available\", not_available: true }}),",
link.uri
)),
false => projects_str.push_str(&format!(
"link: Some(ResumeLink {{ uri: {:?}, label: {:?}, not_available: false }}),",
link.uri, link.label
)),
},
None => projects_str.push_str("link: None,"),
}
// Vector
projects_str.push_str(&format!(
"presentation: &{},",
vec_strings(&project.presentation)
));
// Vector
projects_str.push_str(&format!(
"highlights : &{},",
vec_strings(&project.highlights)
));
// Vector
projects_str.push_str(&format!("keywords: &{},", vec_strings(&project.keywords)));
// Optional struct
match &project.logo {
Some(logo) => projects_str.push_str(&format!(
"logo: Some(Logo {{ file: {:?}, transparent_background: {} }}),",
logo.file, logo.transparent_background
)),
None => projects_str.push_str("logo: None,"),
}
// Optional struct
match &project.image {
Some(image) => projects_str.push_str(&format!(
"image: Some(Image {{ file: {:?}, position: {:?} }}),",
image.file, image.position
)),
None => projects_str.push_str("image: None,"),
}
projects_str.push_str("},\n");
}
projects_str.push(']');
let projects_ser = format!(
"pub const PROJECTS: [Project; {}] = \n{};\n",
projects_data.len(),
projects_str
);
final_ser.push_str(&projects_ser);
// Education
let education_json = &data["education"];
let education_data: Vec<Education> =
serde_json::from_value(education_json.clone()).expect("Failed to parse education");
let mut education_str = String::new();
education_str.push('[');
for education in &education_data {
education_str.push_str("Education {");
education_str.push_str(&format!("institution: {:?},", education.institution));
education_str.push_str(&format!("area: {:?},", education.area));
education_str.push_str(&format!("study_type: {:?},", education.study_type));
education_str.push_str(&format!("start_date: {:?},", education.start_date));
education_str.push_str(&format!(
"end_date: {},",
match &education.end_date {
Some(end) => format!("Some({:?})", end),
None => "None".into(),
}
));
// Optional struct
match &education.logo {
Some(logo) => education_str.push_str(&format!(
"logo: Some(Logo {{ file: {:?}, transparent_background: {} }}),",
logo.file, logo.transparent_background
)),
None => education_str.push_str("logo: None,"),
}
// Vector
let mut courses = String::new();
courses.push('[');
for course in &education.courses {
courses.push_str(&format!("{:?},", course));
}
courses.push(']');
education_str.push_str(&format!("courses: &{},", courses));
education_str.push_str("},\n");
}
education_str.push(']');
let education_ser = format!(
"pub const EDUCATION: [Education; {}] = \n{};\n",
education_data.len(),
education_str
);
final_ser.push_str(&education_ser);
std::fs::write(dest, final_ser).unwrap();
}
fn wallpapers(dest: &std::path::Path, source: std::fs::File) {
let reader = std::io::BufReader::new(source);
let wallpapers_data: Vec<Wallpaper> =
serde_json::from_reader(reader).expect("Failed to parse JSON");
let mut final_ser = String::new();
let mut wallpapers_str = String::new();
wallpapers_str.push('[');
for wallpaper in &wallpapers_data {
wallpapers_str.push_str("Wallpaper {");
wallpapers_str.push_str(&format!("filename: {:?},", wallpaper.filename));
wallpapers_str.push_str(&format!("date: {:?},", wallpaper.date));
wallpapers_str.push_str(&format!(
"gps: Gps {{ latitude: {}, longitude: {} }},",
wallpaper.gps.latitude, wallpaper.gps.longitude
));
wallpapers_str.push_str(&format!(
"location: Location {{ precise: {:?}, broad: {:?} }},",
wallpaper.location.precise, wallpaper.location.broad
));
wallpapers_str.push_str("},\n");
}
wallpapers_str.push(']');
let wallpapers_ser = format!(
"pub const WALLPAPERS: [Wallpaper; {}] = \n{};\n",
wallpapers_data.len(),
wallpapers_str
);
final_ser.push_str(&wallpapers_ser);
std::fs::write(dest, final_ser).unwrap();
}
fn main() {
println!("cargo::rerun-if-changed=resume.json");
println!("cargo::rerun-if-changed=wallpapers.json");
let out_dir = std::env::var_os("OUT_DIR").unwrap();
let resume_dest = std::path::Path::new(&out_dir).join("resume.rs");
let resume_source = std::fs::File::open("resume.json").expect("Failed to open file");
resume(&resume_dest, resume_source);
let wallpapers_dest = std::path::Path::new(&out_dir).join("wallpapers.rs");
match std::fs::File::open("wallpapers.json") {
Ok(source) => wallpapers(&wallpapers_dest, source),
Err(_) => println!("cargo::warning=skipping wallpapers, file not found"),
}
}

24
crates/plcom/css/main.css Normal file
View file

@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Fixed background when scrolling in tailwind
* Disabled on iOS devices
* https://tailwindcss.com/docs/background-attachment
* https://stackoverflow.com/a/60220757/4809297
*/
@supports (-webkit-touch-callout: none) {
/* CSS specific to iOS devices */
#wallpaper {
background-attachment: scroll;
}
}
@supports not (-webkit-touch-callout: none) {
/* CSS for other than iOS devices */
#wallpaper {
background-attachment: fixed;
}
}

428
crates/plcom/resume.json Normal file
View file

@ -0,0 +1,428 @@
{
"$schema": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json",
"basics": {
"name": "Philippe Loctaux",
"url": "https://philippeloctaux.com",
"label": "Developer of all sorts",
"email": "www@philippeloctaux.com",
"location": {
"countryCode": "FR",
"city": "Rennes"
}
},
"languages": [
{
"language": "French"
},
{
"language": "English"
},
{
"language": "Russian"
}
],
"education": [
{
"institution": "Epitech",
"studyType": "Master",
"area": "Computer software engineering",
"startDate": "2018-10",
"endDate": "2023-08",
"logo": {
"file": "/icons/epitech.png",
"transparentBackground": true
},
"courses": [
"Many small projects in C to learn a technical concept during first year",
"my_teams: Microsoft Teams clone in C (protocol design, networking, server, client)",
"Arcade: Small gaming platform capable of loading dynamic libraries with a high abstraction level in C++",
"Epicture: Android client in Kotlin of the Imgur image service (OAuth2, listing, viewing, uploading images)"
]
},
{
"institution": "UQAC",
"studyType": "Year abroad",
"area": "Computer software engineering",
"startDate": "2021-09",
"endDate": "2022-05",
"logo": {
"file": "/icons/uqac.png",
"transparentBackground": true
},
"courses": [
"Android app development",
"Object Oriented Programming",
"Software engineering",
"Entreprise application development"
]
}
],
"work": [
{
"name": "Rubycat",
"position": "Software engineer",
"startDate": "2023-06",
"logo": {
"file": "/icons/rubycat.png",
"transparentBackground": true
},
"description": "Maintenance and improvement of PROVE IT, a Privileged Access Management software platform.",
"highlights": [
"Rewrote an internal component in Rust, making it faster and simpler to use for the development team",
"Bug investigation and fixes",
"Public speaking about the product"
],
"technologies": [
"Rust",
"Angular",
"Python",
"PostgreSQL",
"GitLab"
],
"link": {
"uri": "https://rubycat.eu",
"label": "Company website",
"notAvailable": false
}
},
{
"name": "Acklio",
"position": "Rust developer",
"startDate": "2023-03",
"endDate": "2023-05",
"logo": {
"file": "/icons/acklio.png",
"transparentBackground": true
},
"description": "The first usage of the SCHC framework (RFC 8724) on Rust!",
"highlights": [
"Creation of Rust bindings of a C library implementing the SCHC framework",
"Demonstration of SCHC with applications in Rust on x86 platform",
"Proof of concept usage of embedded STM32 controllers exclusively in Rust",
"Transmission of knowledge to the technical team"
],
"technologies": [
"Rust",
"SCHC",
"STM32 controllers",
"LoRa",
"LoRaWAN"
],
"link": {
"uri": "https://ackl.io",
"label": "Company website",
"notAvailable": false
}
},
{
"name": "Vélorail du Kreiz Breizh",
"position": "Freelance developer",
"startDate": "2021-08",
"endDate": "2022-04",
"logo": {
"file": "/icons/velorail.png",
"transparentBackground": true
},
"description": "Creation of an online booking platform focused on the tourist activity of rail biking (vélorail).",
"highlights": [
"Design, UX, booking and payment flow for customers",
"Dashboard for managers with calendar view, manual bookings, slots management",
"Ability to generate invoices, booking recaps for managers",
"Sending emails to customers and managers about bookings",
"Online deployment, maintenance of the service",
"5 months after the initial deployment, 43% of the bookings were made with the online platform",
"Focus to use the least amount of external services, resulting in implementation of a invoice generation service, an image-based captcha, and a templating system for transactional emails"
],
"technologies": [
"Angular",
"NestJS",
"GraphQL",
"Rust",
"Stripe",
"Amazon SES",
"GitLab CI/CD"
],
"link": {
"uri": "https://resa.velorail.bzh",
"label": "Booking platform",
"notAvailable": true
}
},
{
"name": "Yaakadev",
"position": "Full-Stack developer",
"startDate": "2021-04",
"endDate": "2021-07",
"logo": {
"file": "/icons/yaakadev.png",
"transparentBackground": false
},
"description": "Design, development, deployment and maintenance of many projects for various clients.",
"highlights": [
"Admin dashboard of a local merchants solution",
"Calendar planning application with filtering and custom views for a SaaS for the agency",
"Intranet to upload and download documents for a client"
],
"technologies": [
"NodeJS",
"ExpressJS",
"Angular",
"MongoDB",
"CI/CD"
],
"link": {
"uri": "https://yaakadev.com",
"label": "Agency website",
"notAvailable": false
}
},
{
"name": "Epitech",
"position": "Teaching assistant",
"startDate": "2020-02",
"endDate": "2021-03",
"logo": {
"file": "/icons/epitech.png",
"transparentBackground": true
},
"description": "Pedagogical supervision of three classes of students, conducting educational activites throughout the school year.",
"highlights": [
"Start of projects",
"Technical help and guidance",
"Proctoring exams",
"Grading students on their work"
],
"technologies": [
"C",
"C++",
"Haskell",
"Rust",
"Web and mobile development"
],
"link": {
"uri": "https://www.epitech.eu/ecole-informatique-rennes",
"label": "School",
"notAvailable": false
}
},
{
"name": "Ubiscale",
"position": "Embedded developer",
"startDate": "2019-08",
"endDate": "2019-12",
"logo": {
"file": "/icons/ubiscale.png",
"transparentBackground": true
},
"description": "Creation of a home Wifi gateway for a commercial IoT object.",
"highlights": [
"Research, reverse engineering of existing products",
"Design and implementation"
],
"technologies": [
"C on a ESP8266 controller",
"Wi-Fi",
"Bluetooth"
],
"link": {
"uri": "https://ubiscale.com",
"label": "Company website",
"notAvailable": false
}
}
],
"projects": [
{
"name": "ezidam",
"description": "Identity and Access Management system",
"presentation": [
"A simple identity and access management system for SMEs or personal use.",
"Low maintenance required, easy to deploy and to backup."
],
"highlights": [
"Users management",
"Roles management",
"Assign users to roles and the other way around",
"OAuth2 / OIDC applications (code flow)",
"Multi-Factor Authentication (TOTP)",
"Password reset (via email or backup token)",
"Simple administration panel",
"Good security measures for users and administrators"
],
"keywords": [
"Rust",
"SQLite",
"OAuth2 / OIDC",
"TOTP",
"SMTP",
"Docker"
],
"startDate": "2023-01",
"endDate": "2023-07",
"logo": {
"file": "/icons/ezidam.png",
"transparentBackground": true
},
"link": null
},
{
"name": "pass4thewin",
"description": "Password manager",
"startDate": "2020-11",
"endDate": "2021-01",
"presentation": [
"Port of passwordstore, the standard unix password manager on the Windows platform.",
"Warning! Unfinished command line application, may cause data corruption when using existing passwords."
],
"highlights": [
"Creation of a store",
"List secrets",
"Decrypt secret",
"Insert or generate secrets",
"Edit existing secrets",
"Synchronisation with git",
"TOTP support"
],
"keywords": [
"Windows",
"Rust",
"OpenPGP",
"libgit2"
],
"link": {
"uri": "https://github.com/x4m3/pass4thewin",
"label": "Source code",
"notAvailable": false
}
},
{
"name": "NaviaRent",
"description": "Epitech Innovative Project",
"startDate": "2020-09",
"endDate": "2023-01",
"presentation": [
"A B2B platform helping rentals of standup paddle boards."
],
"highlights": [
"DevOps of all software in the NaviaRent stack",
"Creation of the iOS application",
"Contributions to the Android application",
"Contributions to the backend server",
"Creation and contributions to the web client",
"Server administration, backups",
"Meetings managements spread across 3 timezones",
"Technical writing",
"Public presentations"
],
"keywords": [
"NodeJS",
"Angular",
"Kotlin",
"SwiftUI",
"Docker",
"GitLab CI/CD",
"Raspberry Pi",
"ESP32"
],
"logo": {
"file": "/icons/naviarent.png",
"transparentBackground": false
},
"image": {
"file": "/images/naviarent.jpg",
"position": "right"
},
"link": {
"uri": "https://naviarent.fr",
"label": "Website",
"notAvailable": true
}
},
{
"name": "epitok",
"description": "Presence system at Epitech",
"startDate": "2020-06",
"endDate": "2020-09",
"presentation": [
"A library and web client to simplify students presence at Epitech.",
"Students are handed a piece of paper with a 6 digits number (called a \"token\") to verify their presence at school events.",
"Teachers use epitok to scan student cards with QR codes on them instead of printing and handing tokens to students."
],
"highlights": [
"Reverse engineering of a partially documented web API",
"Design, conception",
"User experience",
"Improvements based of usage of the application"
],
"keywords": [
"Rust",
"HTML",
"Bootstrap",
"jQuery",
"Docker"
],
"link": {
"uri": "https://github.com/x4m3/epitok",
"label": "Source code",
"notAvailable": false
}
},
{
"name": "epi.today",
"description": "Calendar for Epitech",
"startDate": "2019-11",
"endDate": "2019-02",
"presentation": [
"A viewer of the Epitech intranet calendar.",
"Students and teachers glance at their planning without the need to go on the school's intranet."
],
"highlights": [
"Reverse engineering of a web api",
"Design a web page",
"Mobile UX",
"Deployment on a server"
],
"keywords": [
"TypeScript",
"HTML",
"Bootstrap",
"Docker"
],
"link": {
"uri": "https://github.com/x4m3/epi.today",
"label": "Source code",
"notAvailable": false
}
},
{
"name": "canvas.place",
"description": "Timelapse",
"startDate": "2017-04",
"endDate": "2020-01",
"presentation": [
"canvas.place is a shared place to express creativity.",
"People from all over the world share one single canvas to paint on.",
"I created and maintained a timelapse of the virtual canvas."
],
"highlights": [],
"keywords": [
"FFmpeg",
"Shell scripting",
"nginx"
],
"logo": {
"file": "/icons/canvas.png",
"transparentBackground": false
},
"image": {
"file": "/images/canvas.png",
"position": "left"
},
"link": {
"uri": "https://timelapse.canvas.place",
"label": "Website",
"notAvailable": false
}
}
]
}

71
crates/plcom/src/app.rs Normal file
View file

@ -0,0 +1,71 @@
use crate::{
error_template::{AppError, ErrorTemplate},
Link, UnderlineLink,pages::*
};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use tailwind_fuse::*;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
let formatter = |text| format!("{text} — Philippe Loctaux");
view! {
<Stylesheet id="leptos" href="/pkg/plcom.css"/>
// sets the document title
<Title formatter/>
<Meta name="viewport" content="width=device-width"/>
// favicon
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
<Link rel="icon" type_="image/png" sizes="32x32" href="/favicon-32x32.png"/>
<Link rel="icon" type_="image/png" sizes="16x16" href="/favicon-16x16.png"/>
<Link rel="manifest" href="/site.webmanifest"/>
<Link
rel="mask-icon"
href="/safari-pinned-tab.svg"
attrs=vec![("color", Attribute::String(Oco::Borrowed("#0c4a6e")))]
/>
<Meta name="msapplication-TileColor" content="#0c4a6e"/>
<Meta name="theme-color" content="#0c4a6e"/>
// stats
<Script
defer="true"
src="https://plausible.y.z.x4m3.rocks/js/script.js"
attrs=vec![("data-domain", Attribute::String(Oco::Borrowed("philippeloctaux.com")))]
/>
// actual routes
<Router fallback=|| {
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! { <ErrorTemplate outside_errors/> }.into_view()
}>
<Body class=tw_join!("flex", "flex-col", "min-h-screen", "bg-gray-900", "text-white")/>
<main class=tw_join!("flex-grow")>
<Routes>
<Route path="" view=RootPage ssr=SsrMode::Async/>
<Route path="email" view=EmailPage/>
<Route path="wallpapers" view=WallpapersPage/>
</Routes>
</main>
<footer class=tw_join!("bg-black")>
<div class=tw_join!("container", "mx-auto", "px-4", "py-8")>
<p>
"© 2015 - "{crate::get_year()}" Philippe Loctaux, made with "
<UnderlineLink link=Link::new("https://leptos.dev", "Leptos")/>
</p>
</div>
</footer>
</Router>
}
}

137
crates/plcom/src/common.rs Normal file
View file

@ -0,0 +1,137 @@
pub mod icon;
pub mod link;
pub fn get_year() -> i32 {
use chrono::Datelike;
chrono::Utc::now().year()
}
#[derive(Clone, PartialEq)]
pub struct Date {
pub year: u32,
pub month: u8,
}
impl std::fmt::Display for Date {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{:02}", self.year, self.month)
}
}
pub mod resume {
#[derive(Clone, Copy)]
pub struct Work {
pub name: &'static str,
pub position: &'static str,
pub start_date: &'static str,
pub end_date: Option<&'static str>,
pub logo: Logo,
pub description: &'static str,
pub highlights: &'static [&'static str],
pub technologies: &'static [&'static str],
pub link: Option<ResumeLink>,
}
#[derive(Clone, Copy)]
pub struct Logo {
pub file: &'static str,
pub transparent_background: bool,
}
#[derive(Clone, Copy)]
pub struct Project {
pub name: &'static str,
pub description: &'static str,
pub start_date: &'static str,
pub end_date: Option<&'static str>,
pub presentation: &'static [&'static str],
pub highlights: &'static [&'static str],
pub keywords: &'static [&'static str],
pub link: Option<ResumeLink>,
pub logo: Option<Logo>,
pub image: Option<Image>,
}
#[derive(Clone, Copy)]
pub struct Image {
pub file: &'static str,
pub position: &'static str,
}
#[derive(Clone, Copy)]
pub struct ResumeLink {
pub uri: &'static str,
pub label: &'static str,
pub not_available: bool,
}
use crate::{Link, OutlineButtonLink};
use http::Uri;
use leptos::*;
use tailwind_fuse::tw_join;
impl IntoView for ResumeLink {
fn into_view(self) -> View {
if !self.not_available {
let link = Link {
label: self.label.into(),
uri: Uri::from_static(self.uri),
};
view! { <OutlineButtonLink link=link/> }.into_view()
} else {
view! {
<span class=tw_join!(
"mt-4", "cursor-not-allowed", "inline-flex", "max-w-fit", "bg-gray-400",
"text-gray-600", "font-semibold", "py-1.5", "px-4", "rounded-xl",
"items-center"
)>{self.label}</span>
}
.into_view()
}
}
}
#[derive(Clone, Copy)]
pub struct Education {
pub institution: &'static str,
pub study_type: &'static str,
pub area: &'static str,
pub start_date: &'static str,
pub end_date: Option<&'static str>,
pub logo: Option<Logo>,
pub courses: &'static [&'static str],
}
include!(concat!(env!("OUT_DIR"), "/resume.rs"));
}
pub mod wallpapers {
#[derive(Clone, Copy)]
pub struct Wallpaper {
pub filename: &'static str,
pub date: &'static str,
pub gps: Gps,
pub location: Location,
}
#[derive(Clone, Copy)]
pub struct Gps {
pub latitude: f32,
pub longitude: f32,
}
#[derive(Clone, Copy)]
pub struct Location {
pub precise: &'static str,
pub broad: &'static str,
}
impl Wallpaper {
pub fn random() -> Option<&'static Wallpaper> {
let random_value = rand::Rng::gen_range(&mut rand::thread_rng(), 0..WALLPAPERS.len());
WALLPAPERS.get(random_value)
}
}
include!(concat!(env!("OUT_DIR"), "/wallpapers.rs"));
}

View file

@ -0,0 +1,140 @@
use leptos::*;
use tailwind_fuse::tw_join;
#[derive(Clone, Copy, PartialEq)]
pub enum Icon {
Email,
Link,
Calendar,
Location,
Twitter,
Telegram,
Mastodon,
Github,
Linkedin,
Map,
}
impl IntoView for Icon {
fn into_view(self) -> View {
match self {
Self::Email => view! {
<svg
class=tw_join!("w-6", "h-6", "mr-0", "sm:mr-2")
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
></path>
</svg>
}.into_view(),
Self::Link => view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class=tw_join!("w-5", "h-5", "fill-current", "mr-2")
>
<path d="M12.232 4.232a2.5 2.5 0 013.536 3.536l-1.225 1.224a.75.75 0 001.061 1.06l1.224-1.224a4 4 0 00-5.656-5.656l-3 3a4 4 0 00.225 5.865.75.75 0 00.977-1.138 2.5 2.5 0 01-.142-3.667l3-3z"></path>
<path d="M11.603 7.963a.75.75 0 00-.977 1.138 2.5 2.5 0 01.142 3.667l-3 3a2.5 2.5 0 01-3.536-3.536l1.225-1.224a.75.75 0 00-1.061-1.06l-1.224 1.224a4 4 0 105.656 5.656l3-3a4 4 0 00-.225-5.865z"></path>
</svg>
}.into_view(),
Self::Calendar => view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class=tw_join!("w-5", "h-5")
>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"
></path>
</svg>
}.into_view(),
Self::Location => view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class=tw_join!("w-5", "h-5")
>
<path
fill-rule="evenodd"
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z"
clip-rule="evenodd"
></path>
</svg>
}.into_view(),
Icon::Twitter => view! {
<svg
class=tw_join!("w-6", "h-6", "fill-current", "mr-0", "sm:mr-2")
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
</svg>
}.into_view(),
Icon::Telegram => view! {
<svg
class=tw_join!("w-6", "h-6", "fill-current", "mr-0", "sm:mr-2")
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
</svg>
}.into_view(),
Icon::Mastodon => view! {
<svg
class=tw_join!("w-6", "h-6", "fill-current", "mr-0", "sm:mr-2")
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"></path>
</svg>
}.into_view(),
Icon::Github => view! {
<svg
class=tw_join!("w-6", "h-6", "fill-current", "mr-0", "sm:mr-2")
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
</svg>
}.into_view(),
Icon::Linkedin => view! {
<svg
class=tw_join!("w-6", "h-6", "fill-current", "mr-0", "sm:mr-2")
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
</svg>
}.into_view(),
Icon::Map => view! {
<svg
class=tw_join!("w-5", "h-5")
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.157 2.175a1.5 1.5 0 00-1.147 0l-4.084 1.69A1.5 1.5 0 002 5.251v10.877a1.5 1.5 0 002.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 001.146 0l4.083-1.69A1.5 1.5 0 0018 14.748V3.873a1.5 1.5 0 00-2.073-1.386l-3.51 1.452-4.26-1.763zM7.58 5a.75.75 0 01.75.75v6.5a.75.75 0 01-1.5 0v-6.5A.75.75 0 017.58 5zm5.59 2.75a.75.75 0 00-1.5 0v6.5a.75.75 0 001.5 0v-6.5z"
clip-rule="evenodd"
></path>
</svg>
}.into_view(),
}
}
}

View file

@ -0,0 +1,99 @@
use http::Uri;
use leptos::*;
use tailwind_fuse::tw_join;
use crate::Icon;
#[derive(Clone, PartialEq)]
pub struct Link {
pub label: String,
pub uri: Uri,
}
impl Link {
pub fn new(uri: &'static str, label: impl Into<String>) -> Self {
Self {
uri: Uri::from_static(uri),
label: label.into(),
}
}
pub fn slides(uri: &'static str) -> Self {
Self::new(uri, "Slides")
}
}
#[component]
pub fn UnderlineLink(
#[prop(into)] link: MaybeSignal<Link>,
#[prop(into, optional)] class: MaybeSignal<String>,
#[prop(attrs)] attributes: Vec<(&'static str, Attribute)>,
) -> impl IntoView {
let class = tailwind_fuse::tw_merge!("underline", class.get());
view! {
<a href=link.get().uri.to_string() {..attributes} class=class target="_blank">
{link.get().label}
</a>
}
}
type HideTextSmallDisplay = bool;
#[component]
pub fn ButtonLink(
#[prop(into)] link: MaybeSignal<Link>,
#[prop(into, optional)] icon: Option<MaybeSignal<Icon>>,
#[prop(into, optional)] hide_text_small_display: Option<MaybeSignal<HideTextSmallDisplay>>,
#[prop(attrs)] attributes: Vec<(&'static str, Attribute)>,
) -> impl IntoView {
let text_css = hide_text_small_display
.and_then(|hide| {
if hide.get() {
Some(tw_join!("hidden", "sm:inline"))
} else {
None
}
})
.unwrap_or(tw_join!("ml-2", "sm:ml-0", "text-center"));
view! {
<a
href=link.get().uri.to_string()
{..attributes}
class=tw_join!(
"inline-flex", "bg-sky-900", "hover:bg-sky-700", "transition-all", "duration-200",
"text-white", "font-bold", "py-2", "px-4", "rounded-xl", "items-center"
)
>
{icon}
<div class=tw_join!("inline-flex", "items-center")>
<span class=text_css>{link.get().label}</span>
</div>
</a>
}
}
#[component]
pub fn OutlineButtonLink(
#[prop(into)] link: MaybeSignal<Link>,
#[prop(attrs)] attributes: Vec<(&'static str, Attribute)>,
) -> impl IntoView {
view! {
<a
href=link.get().uri.to_string()
{..attributes}
class=tw_join!(
"mt-4", "inline-flex", "bg-transparent", "hover:bg-sky-700", "text-white",
"font-semibold", "py-1.5", "px-4", "rounded-xl", "items-center", "border",
"border-white", "hover:border-transparent", "transition-all", "duration-200"
)
>
{Icon::Link}
<div class=tw_join!("inline-flex", "items-center")>
<span class=tw_join!("ml-2", "sm:ml-0", "text-center")>{link.get().label}</span>
</div>
</a>
}
}

View file

@ -0,0 +1,83 @@
use crate::ContentPage;
use http::status::StatusCode;
use leptos::*;
use thiserror::Error;
#[derive(Clone, Debug, Error)]
pub enum AppError {
#[error("Not Found")]
NotFound,
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
}
}
pub fn canonical_reason(&self) -> String {
match self {
AppError::NotFound => StatusCode::NOT_FOUND.to_string(),
}
}
pub fn description(&self) -> String {
match self {
AppError::NotFound => "This page could not be found.".to_string(),
}
}
}
// A basic function to display errors served by the error boundaries.
// Feel free to do more complicated things here than just displaying the error.
#[component]
pub fn ErrorTemplate(
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
let errors = errors.get_untracked();
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<AppError> = errors
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
.collect();
println!("Errors: {errors:#?}");
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
#[cfg(feature = "ssr")]
{
use leptos_axum::ResponseOptions;
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
response.set_status(errors[0].status_code());
}
}
view! {
<For
// a function that returns the items we're iterating over; a signal is fine
each=move || { errors.clone().into_iter().enumerate() }
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
children=move |error| {
view! {
<ContentPage title=error.1.canonical_reason()>
<p>{error.1.description()}</p>
</ContentPage>
}
}
/>
}
}

View file

@ -0,0 +1,42 @@
use axum::{
body::Body,
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::*;
use crate::app::App;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}

45
crates/plcom/src/lib.rs Normal file
View file

@ -0,0 +1,45 @@
use leptos::*;
use prelude::*;
pub mod app;
pub mod common;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fileserv;
pub mod pages;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
console_error_panic_hook::set_once();
mount_to_body(App);
}
pub mod prelude {
pub use super::ContentPage;
pub use crate::common::icon::*;
pub use crate::common::link::*;
pub use crate::common::resume::*;
pub use crate::common::wallpapers::*;
pub use crate::common::*;
pub use leptos::*;
pub use leptos_meta::*;
pub use tailwind_fuse::tw_join;
pub use http::Uri;
}
#[component]
pub fn ContentPage(
#[prop(into, optional)] title: MaybeSignal<String>,
children: Children,
) -> impl IntoView {
view! {
<leptos_meta::Title text=title.get()></leptos_meta::Title>
<div class=tw_join!("container", "mx-auto", "px-4", "py-16")>
<h1 class=tw_join!("text-3xl", "sm:text-4xl", "font-bold")>{title}</h1>
<UnderlineLink link=Link::new("/", "← Home")/>
<div class=tw_join!("mt-8")>{children()}</div>
</div>
}
}

38
crates/plcom/src/main.rs Normal file
View file

@ -0,0 +1,38 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use plcom::app::*;
use plcom::fileserv::file_and_error_handler;
// Setting get_configuration(None) means we'll be using cargo-leptos's env values
// For deployment these variables are:
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
// Alternately a file can be specified such as Some("Cargo.toml")
// The file would need to be included with the executable when moved to deployment
let conf = get_configuration(None).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
// build our application with a route
let app = Router::new()
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
logging::log!("listening on http://{}", &addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead
}

View file

@ -0,0 +1,7 @@
pub mod root;
pub mod email;
pub mod wallpapers;
pub use root::RootPage;
pub use email::EmailPage;
pub use wallpapers::WallpapersPage;

View file

@ -0,0 +1,37 @@
use crate::prelude::*;
#[component]
pub fn EmailPage() -> impl IntoView {
view! {
<ContentPage title="Email">
<p>
"Send an email if you want to work with me, propose a project idea, or just to say hi!"
</p>
<div class=tw_join!("my-4")>
<ButtonLink
link=Link::new(
"mailto:wwwATphilippeloctaux~DOT~com",
"www at philippeloctaux dot com",
)
icon=Icon::Email
/>
</div>
<p class=tw_join!(
"mb-2"
)>
"If you want to encrypt your message, I have a "
<UnderlineLink link=Link::new("/pub/pgp-0x69771CD04BA82EC0.txt", "pgp key")/>
" at your disposal."
</p>
<p class=tw_join!(
"mb-2"
)>
"I also have a " <UnderlineLink link=Link::new("/keybase.txt", "Keybase")/>
" account, but I do not check it often."
</p>
</ContentPage>
}
}

View file

@ -0,0 +1,76 @@
use crate::prelude::*;
mod hero;
mod www;
mod experience;
mod jobs;
mod projects;
mod education;
mod talks;
mod friends;
#[component]
pub fn RootPage() -> impl IntoView {
let random_wallpaper = Wallpaper::random();
view! {
<Title text="Hello"/>
<hero::Hero wallpaper=random_wallpaper></hero::Hero>
<div class=tw_join!("container", "mx-auto", "px-4", "md:px-8", "lg:px-16", "py-16")>
<Whoami/>
<div class=tw_join!("my-16", "space-y-16", "md:space-y-32")>
<www::Www></www::Www>
<jobs::Jobs></jobs::Jobs>
<projects::Projects></projects::Projects>
<education::EducationList></education::EducationList>
<talks::Talks></talks::Talks>
<friends::Friends></friends::Friends>
</div>
</div>
}
}
#[component]
fn Whoami() -> impl IntoView {
view! {
<div class=tw_join!("md:flex", "md:flex-row-reverse", "items-center")>
<div class=tw_join!("md:w-1/2", "mb-4", "md:mb-0")>
<img
src="/phil.png"
alt="Phil"
class=tw_join!(
"rounded-3xl", "bg-sky-900", "h-36", "w-36", "md:mx-auto", "md:h-56",
"md:w-56", "lg:h-64", "lg:w-64", "mb-2", "md:mb-0"
)
/>
</div>
<div class=tw_join!("md:w-1/2")>
<h1 class=tw_join!("text-4xl", "font-bold", "mb-4")>"About Phil"</h1>
<h2 class=tw_join!(
"text-2xl", "font-semibold", "mb-4"
)>"Developer of all sorts"</h2>
<div class=tw_join!("text-lg", "space-y-6")>
<p>
"I got into computer science by learning about the Linux kernel and administrating servers."
</p>
<p>
"After high school, I became a student at Epitech and learned to tackle technical concepts and apply them quickly by working on small projects."
</p>
<p>
"During my studies at Epitech, I had the opportunity to be a teacher. My role was to assist students with technical problems in their projects."
</p>
<p>
"Now I have experience in software engineering, full-stack web and mobile development, system administration and CI/CD, as well as embedded development."
</p>
<p>
"My goal is to use my knowledge and experience to make software helping its users accomplish their needs."
</p>
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,51 @@
use crate::prelude::*;
use super::experience::*;
impl IntoView for Education {
fn into_view(self) -> View {
let subtitle = format!("{} in {}", self.study_type, self.area);
view! {
<div class=tw_join!(
"rounded-2xl", "w-full", "bg-amber-950", "p-6"
)>
{ExperienceHeader::new(
self.start_date,
self.end_date,
self.institution,
&subtitle,
self.logo.as_ref(),
)}
<div class=tw_join!("space-y-2")>
<ul class=tw_join!(
"list-disc", "mt-6"
)>
{self
.courses
.iter()
.map(|h| {
view! { <li class=tw_join!("ml-5")>{*h}</li> }
})
.collect_view()}
</ul>
</div>
</div>
}.into_view()
}
}
#[component]
pub fn EducationList() -> impl IntoView {
view! {
<div>
<h1 class=tw_join!("text-4xl", "font-bold", "mb-4")>"Education"</h1>
<div class=tw_join!(
"mt-4", "grid", "grid-cols-1", "md:grid-cols-2", "gap-6", "place-content-center"
)>{resume::EDUCATION.collect_view()}</div>
</div>
}
}

View file

@ -0,0 +1,134 @@
use tailwind_fuse::*;
use leptos::*;
use crate::common::Date;
use crate::common::resume::Logo;
#[derive(TwClass, Clone, Copy, PartialEq)]
#[tw(class = r#"h-16 w-16 rounded-xl"#)]
struct LogoOptions {
background: ImageBackground,
}
#[derive(TwVariant, PartialEq)]
enum ImageBackground {
#[tw(class = "p-2 bg-white")]
Transparent,
#[tw(default, class = "")]
Plain,
}
#[component]
fn ExperienceLogo(
#[prop(into)] image: MaybeSignal<String>,
/// Name of the experience, used in the alt of the image
#[prop(into)]
name: MaybeSignal<String>,
#[prop(into, optional)] background: MaybeSignal<ImageBackground>,
#[prop(into, optional)] class: MaybeSignal<String>,
#[prop(attrs)] attributes: Vec<(&'static str, Attribute)>,
) -> impl IntoView {
let class = create_memo(move |_| {
let background = background.get();
let logo = LogoOptions { background };
logo.with_class(class.get())
});
let alt = format!("{} logo", name.get());
view! { <img {..attributes} loading="lazy" src=image.get() alt=alt class=class/> }
}
struct ExperienceLogo {
file: String,
options: Option<LogoOptions>,
}
pub struct ExperienceHeader {
name: String,
description: String,
date_start: Date,
date_end: Option<Date>,
logo: Option<ExperienceLogo>,
}
impl IntoView for ExperienceHeader {
fn into_view(self) -> View {
let logo = match self.logo {
Some(logo) => view! {
<ExperienceLogo
image=logo.file
name=self.name.clone()
background=logo.options.map(|o| o.background).unwrap_or_default()
class=tw_join!("mr-4")
/>
}
.into_view(),
None => view! {}.into_view(),
};
let date = match self.date_end {
Some(end) => format!("{} - {}", self.date_start, end),
None => format!("Since {}", self.date_start),
};
view! {
<div class=tw_join!("flex", "flex-col")>
<div class=tw_join!(
"flex", "flex-row"
)>
{logo} <div class=tw_join!("flex", "flex-col", "justify-evenly")>
<div class=tw_join!(
"text-xl", "md:text-2xl", "font-semibold"
)>{self.name}</div>
<div class=tw_join!("text-xs", "md:text-sm")>{date}</div>
</div>
</div>
<p class=tw_join!(
"text-xl", "md:text-2xl", "font-semibold", "my-4"
)>{self.description}</p>
</div>
}
.into_view()
}
}
impl ExperienceHeader {
pub fn new(
start_date: &str,
end_date: Option<&str>,
name: &str,
description: &str,
logo: Option<&Logo>,
) -> Self {
let date_start: Vec<_> = start_date.split('-').collect();
let date_end = end_date.map(|end| {
let end: Vec<_> = end.split('-').collect();
Date {
year: end[0].parse().expect("not a number"),
month: end[1].parse().expect("not a number"),
}
});
let logo = logo.map(|logo| ExperienceLogo {
file: logo.file.into(),
options: if logo.transparent_background {
Some(LogoOptions {
background: ImageBackground::Transparent,
})
} else {
None
},
});
Self {
name: name.into(),
description: description.into(),
date_start: Date {
year: date_start[0].parse().expect("not a number"),
month: date_start[1].parse().expect("not a number"),
},
date_end,
logo,
}
}
}

View file

@ -0,0 +1,145 @@
use crate::prelude::*;
#[derive(Clone, PartialEq)]
struct Name {
first: String,
last: Option<String>,
}
impl Name {
pub fn nick(nick: impl Into<String>) -> Self {
Self {
first: nick.into(),
last: None,
}
}
pub fn new(first: impl Into<String>, last: impl Into<String>) -> Self {
Self {
first: first.into(),
last: Some(last.into()),
}
}
pub fn initials(&self) -> String {
let first = self
.first
.to_uppercase()
.chars()
.next()
.expect("Invalid first name");
let last = self
.last
.as_ref()
.and_then(|last| last.to_uppercase().chars().next());
match last {
Some(last) => format!("{first}{last}"),
None => first.into(),
}
}
}
impl std::fmt::Display for Name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.last {
Some(last) => write!(f, "{} {}", self.first, last),
None => write!(f, "{}", self.first),
}
}
}
#[derive(Clone, PartialEq)]
struct Friend {
name: Name,
uri: Uri,
}
impl Friend {
pub fn nick(nick: impl Into<String>, uri: &'static str) -> Self {
Self {
name: Name::nick(nick),
uri: Uri::from_static(uri),
}
}
pub fn new(first: impl Into<String>, last: impl Into<String>, uri: &'static str) -> Self {
Self {
name: Name::new(first, last),
uri: Uri::from_static(uri),
}
}
pub fn domain_name(&self) -> String {
self.uri
.authority()
.map(|authority| authority.to_string())
.unwrap_or_else(|| self.uri.to_string())
}
}
#[component]
fn Friend(#[prop(into)] friend: MaybeSignal<Friend>) -> impl IntoView {
view! {
<a
href=friend.get().uri.to_string()
target="_blank"
class=tw_join!(
"hover:bg-gray-500", "transition-all", "duration-200", "flex", "items-center",
"rounded-lg", "p-2"
)
>
<span class=tw_join!(
"rounded-full", "flex-shrink-0", "mr-4", "w-10", "h-10", "bg-sky-900", "text-white",
"flex", "items-center", "justify-center", "text-lg", "font-medium"
)>{friend.get().name.initials()}</span>
<div>
<p class=tw_join!("font-bold")>{friend.get().name.to_string()}</p>
<p>{friend.get().domain_name()}</p>
</div>
</a>
}
}
#[component]
pub fn Friends() -> impl IntoView {
let friends = [
Friend::new("Paolo", "Rotolo", "https://rotolo.dev"),
Friend::new("Polly", "Bishop", "https://github.com/itspolly"),
Friend::new("Ayden", "Panhuyzen", "https://ayden.dev"),
Friend::new("Corbin", "Crutchley", "https://crutchcorn.dev"),
Friend::new("James", "Fenn", "https://jfenn.me"),
Friend::new("Alex", "Dueppen", "https://ajd.sh"),
Friend::new("Lyra", "Messier", "https://lyramsr.co"),
Friend::new("Peter", "Soboyejo", "https://twitter.com/pxtvr"),
Friend::nick("Millomaker", "https://youtube.com/millomaker"),
Friend::new("Alexandre", "Wagner", "https://wagnerwave.com"),
Friend::new("Aidan", "Follestad", "https://af.codes"),
Friend::new("Victor", "Simon", "https://simonvictor.com"),
];
view! {
<div>
<h1 class=tw_join!("text-4xl", "font-bold", "mb-4")>"Friends"</h1>
<p class=tw_join!("text-lg")>"Folks I worked with, or I like what they do."</p>
<ul class=tw_join!(
"my-4", "grid", "grid-cols-1", "sm:grid-cols-2", "md:grid-cols-3", "lg:grid-cols-4",
"sm:gap-4"
)>
{friends
.into_iter()
.map(|f| {
view! {
<li class=tw_join!("py-2")>
<Friend friend=f/>
</li>
}
})
.collect_view()}
</ul>
<p>"If you do not appear here and we know each other, hit me up!"</p>
</div>
}
}

View file

@ -0,0 +1,100 @@
use crate::prelude::*;
#[component]
fn WallpaperInfo(#[prop(into)] wallpaper: &'static Wallpaper) -> impl IntoView {
view! {
<div class=tw_join!(
"absolute", "bottom-3", "sm:bottom-5", "left-2", "sm:left-5", "inline-block",
"backdrop-blur-lg", "backdrop-brightness-75", "rounded-xl", "shadow-2xl", "p-2",
"space-y-0.5", "sm:space-y-2"
)>
// See more
<div class=tw_join!("flex")>
<div class=tw_join!(
"inline-flex", "items-center"
)>
{Icon::Map}
<a
class=tw_join!("ml-1", "text-sm", "underline")
href="/wallpapers"
target="_blank"
>
"See more!"
</a>
</div>
</div>
// Location
<div class=tw_join!("flex")>
<div class=tw_join!(
"inline-flex", "items-center"
)>
{Icon::Location}
<span class=tw_join!(
"ml-1", "text-sm"
)>
{wallpaper.location.precise}
<span class=tw_join!(
"hidden", "md:inline"
)>", "{wallpaper.location.broad}</span>
</span>
</div>
</div>
// Date
<div class=tw_join!("flex")>
<div class=tw_join!(
"inline-flex", "items-center"
)>
{Icon::Calendar} <span class=tw_join!("ml-1", "text-sm")>{wallpaper.date}</span>
</div>
</div>
</div>
}
}
#[component]
pub fn Hero(#[prop(into)] wallpaper: Option<&'static Wallpaper>) -> impl IntoView {
let (wallpaper_info, background_image) = match wallpaper {
Some(wallpaper) => (
view! { <WallpaperInfo wallpaper=wallpaper/> }.into_view(),
format!("background-image: url(/wallpapers/{});", wallpaper.filename),
),
None => (view! {}.into_view(), "".into()),
};
view! {
<div class=tw_join!("bg-gradient-to-r", "from-red-900", "via-teal-900", "to-fuchsia-900")>
<div
id="wallpaper"
class=tw_join!(
"relative", "text-white", "w-full", "h-almostscreen", "bg-center", "bg-cover"
)
style=background_image
>
<div class=tw_join!(
"container", "mx-auto", "px-8", "py-16", "w-full", "h-full", "justify-center",
"items-center", "flex", "flex-col"
)>
<div class=tw_join!(
"inline-block", "backdrop-blur-lg", "backdrop-brightness-75", "rounded-3xl",
"shadow-2xl", "px-4", "py-6", "sm:px-8", "sm:py-12", "space-y-4"
)>
<h1 class=tw_join!(
"text-3xl", "sm:text-4xl", "font-bold"
)>"Philippe Loctaux"</h1>
<h2 class=tw_join!(
"sm:text-xl", "font-semibold"
)>"Developer of all sorts. Epitech alumni, class of 2023."</h2>
</div>
</div>
{wallpaper_info}
</div>
</div>
}
}

View file

@ -0,0 +1,76 @@
use crate::prelude::*;
use super::experience::*;
impl IntoView for Work {
fn into_view(self) -> View {
view! {
<div class=tw_join!("w-full", "rounded-2xl", "bg-sky-950")>
<div class=tw_join!(
"p-6", "justify-between", "h-full"
)>
{ExperienceHeader::new(
self.start_date,
self.end_date,
self.name,
self.position,
Some(&self.logo),
)} <div class=tw_join!("space-y-2")>
<p>{self.description}</p>
<div>
<ul class=tw_join!(
"list-disc", "mt-6"
)>
{self
.highlights
.iter()
.map(|h| {
view! { <li class=tw_join!("ml-5")>{*h}</li> }
})
.collect_view()}
</ul>
</div>
<div>
<div class=tw_join!(
"mt-6 flex flex-wrap gap-x-6 gap-y-4"
)>
{self
.technologies
.iter()
.map(|t| {
view! {
<span class=tw_join!(
"inline-flex", "items-center", "rounded-md", "bg-blue-100",
"px-2", "py-1", "font-medium", "text-blue-700"
)>{*t}</span>
}
})
.collect_view()}
</div>
</div>
</div> {self.link}
</div>
</div>
}.into_view()
}
}
#[component]
pub fn Jobs() -> impl IntoView {
view! {
<div>
<h1 class=tw_join!("text-4xl", "font-bold", "mb-4")>"Professional Experiences"</h1>
<div class=tw_join!(
"mt-4", "grid", "grid-cols-1", "sm:grid-cols-2", "gap-4"
)>{resume::WORK.collect_view()}</div>
</div>
}
}

View file

@ -0,0 +1,163 @@
use crate::prelude::*;
use super::experience::*;
impl IntoView for Project {
fn into_view(self) -> View {
let (css_image_position, css_image_position_corner) = match self.image {
Some(image) => {
let position = match image.position {
"left" => tw_join!("2xl:flex items-stretch justify-between"),
"right" => {
tw_join!("2xl:flex items-stretch justify-between 2xl:flex-row-reverse")
}
_ => todo!("match on an enum instead of raw strings"),
};
let corner = match image.position {
"left" => tw_join!("2xl:rounded-tr-none", "2xl:rounded-l-2xl"),
"right" => tw_join!("2xl:rounded-tl-none", "2xl:rounded-r-2xl"),
_ => todo!("match on an enum instead of raw strings"),
};
let corner = tw_join!(
"flex",
"w-full",
"2xl:w-1/2",
"grow",
"rounded-t-2xl",
"object-cover",
corner
);
(position, corner)
}
None => ("".into(), "".into()),
};
view! {
<div class=tw_join!(
"w-full", "rounded-2xl", "bg-pink-950", css_image_position
)>
{if let Some(image) = self.image {
view! {
<img
loading="lazy"
src=image.file
alt=format!("{} image", self.name)
class=css_image_position_corner
/>
}
.into_view()
} else {
view! {}.into_view()
}}
<div class=tw_join!(
"p-6", "justify-between", "h-full"
)>
{ExperienceHeader::new(
self.start_date,
self.end_date,
self.name,
self.description,
self.logo.as_ref(),
)}
<div class=tw_join!(
"space-y-2"
)>
{self
.presentation
.iter()
.map(|p| {
view! { <p>{*p}</p> }
})
.collect_view()} <div>
<ul class=tw_join!(
"list-disc", "mt-6"
)>
{self
.highlights
.iter()
.map(|h| {
view! { <li class=tw_join!("ml-5")>{*h}</li> }
})
.collect_view()}
</ul>
</div> <div>
<div class=tw_join!(
"mt-6", "grid", "grid-cols-2", "sm:grid-cols-3", "gap-x-6",
"gap-y-4"
)>
{self
.keywords
.iter()
.map(|t| {
view! {
<span class=tw_join!(
"items-center", "rounded-md", "bg-blue-100", "px-2", "py-1",
"font-medium", "text-blue-700",
)>{*t}</span>
}
})
.collect_view()}
</div>
</div>
</div> {self.link}
</div>
</div>
}.into_view()
}
}
type ImageProject = Project;
type TextProject = Project;
enum DisplayProject {
Text(Box<(TextProject, Option<TextProject>)>),
Image(ImageProject)
}
impl IntoView for DisplayProject {
fn into_view(self) -> View {
match self {
Self::Image(image) => image.into_view(),
Self::Text(boxy) => {
let (text1, text2) = *boxy;
view! {
<div class=tw_join!(
"my-4", "grid", "grid-cols-1", "sm:grid-cols-2", "gap-4"
)>{text1} {text2}</div>
}.into_view()
}
}
}
}
#[component]
pub fn Projects() -> impl IntoView {
let mut projects = vec![];
let mut iter = resume::PROJECTS.iter();
while let Some(cur_proj) = iter.next() {
if cur_proj.image.is_some() {
projects.push(DisplayProject::Image(*cur_proj));
} else {
let next_text = iter.next();
projects.push(DisplayProject::Text(Box::new((*cur_proj, next_text.copied()))));
}
}
view! {
<div>
<h1 class=tw_join!("text-4xl", "font-bold", "mb-4")>"Projects"</h1>
{projects}
</div>
}
}

View file

@ -0,0 +1,137 @@
use crate::prelude::*;
#[derive(Clone, PartialEq)]
struct Talk {
title: String,
date: Date,
location: String,
link: Link,
}
impl Talk {
pub fn new(
title: impl Into<String>,
date: Date,
location: impl Into<String>,
link: Link,
) -> Self {
Self {
title: title.into(),
date,
location: location.into(),
link,
}
}
}
#[component]
fn Talk(#[prop(into)] talk: MaybeSignal<Talk>) -> impl IntoView {
view! {
<div class=tw_join!("rounded-2xl", "w-full", "bg-teal-950", "p-6")>
<h3 class=tw_join!("text-xl", "font-semibold", "mb-4")>{talk.get().title}</h3>
<div class=tw_join!("flex")>
<div class=tw_join!(
"inline-flex", "items-center"
)>
{Icon::Calendar}
<span class=tw_join!("ml-2")>{talk.get().date.to_string()}</span>
</div>
</div>
<div class=tw_join!("flex")>
<div class=tw_join!(
"inline-flex", "items-center"
)>{Icon::Location} <span class=tw_join!("ml-2")>{talk.get().location}</span></div>
</div>
<OutlineButtonLink link=talk.get().link/>
</div>
}
}
#[component]
pub fn Talks() -> impl IntoView {
let talks = [
Talk::new(
"Vim",
Date {
year: 2023,
month: 2,
},
"Epitech Rennes",
Link::slides("/pub/talks/vim.pdf"),
),
Talk::new(
"CLion",
Date {
year: 2021,
month: 3,
},
"Epitech Rennes",
Link::slides("/pub/talks/clion.pdf"),
),
Talk::new(
"git & devops 2",
Date {
year: 2021,
month: 2,
},
"Epitech Rennes",
Link::slides("/pub/talks/git-devops2.pdf"),
),
Talk::new(
"pass4thewin",
Date {
year: 2021,
month: 2,
},
"Epitech Rennes",
Link::slides("/pub/talks/pass4thewin.pdf"),
),
Talk::new(
"git & devops",
Date {
year: 2020,
month: 5,
},
"Epitech Rennes",
Link::slides("/pub/talks/git-devops.pdf"),
),
Talk::new(
"git gud",
Date {
year: 2019,
month: 5,
},
"Epitech Rennes",
Link::slides("/pub/talks/git-tek.pdf"),
),
];
view! {
<div>
<h1 class=tw_join!("text-4xl", "font-bold", "mb-4")>"Talks"</h1>
<p class=tw_join!(
"text-lg"
)>
"Giving a talk is the opportunity to share what I know, and helps me reduce my fear of public speaking."
</p>
<div class=tw_join!(
"mt-4", "grid", "grid-cols-1", "sm:grid-cols-2", "lg:grid-cols-3", "gap-6",
"place-content-center"
)>
{talks
.into_iter()
.map(|t| {
view! { <Talk talk=t/> }
})
.collect_view()}
</div>
</div>
}
}

View file

@ -0,0 +1,65 @@
use crate::prelude::*;
#[derive(Clone, PartialEq)]
struct Www {
link: Link,
icon: Icon,
}
#[component]
pub fn Www() -> impl IntoView {
let www = [
Www {
link: Link::new("https://twitter.com/philippeloctaux", "Twitter"),
icon: Icon::Twitter,
},
Www {
link: Link::new("https://t.me/philippeloctaux", "Telegram"),
icon: Icon::Telegram,
},
Www {
link: Link::new("https://mastodon.social/@philt3r", "Mastodon"),
icon: Icon::Mastodon,
},
Www {
link: Link::new("https://github.com/x4m3", "GitHub"),
icon: Icon::Github,
},
Www {
link: Link::new("https://linkedin.com/in/philippeloctaux", "LinkedIn"),
icon: Icon::Linkedin,
},
Www {
link: Link::new("/email", "Email"),
icon: Icon::Email,
},
];
view! {
<div class=tw_join!(
"grid", "grid-cols-3", "lg:grid-cols-6", "gap-4", "place-content-center"
)>
{www
.into_iter()
.map(|w| {
view! {
<div class=tw_join!("w-full", "h-auto", "md:w-auto")>
<div class=tw_join!("text-center")>
<ButtonLink
link=w.link
icon=w.icon
hide_text_small_display=true
attributes=vec![
("target", Attribute::String(Oco::Borrowed("_blank"))),
]
/>
</div>
</div>
}
})
.collect_view()}
</div>
}
}

View file

@ -0,0 +1,49 @@
use crate::prelude::*;
#[component]
pub fn WallpapersPage() -> impl IntoView {
use leptos_leaflet::{TileLayer, MapContainer, Position, Marker, position, Popup};
let wallpapers = WALLPAPERS;
view! {
<ContentPage title="Wallpapers">
<Stylesheet href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<Script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"/>
<p class=tw_join!("mb-2")>"Pictures I took around the world"</p>
<MapContainer
center=Position::new(0.0, 0.0)
zoom=1.0
class=tw_join!("w-full", "h-halfscreen")
>
<TileLayer
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"
/>
<For
each=move || wallpapers
key=|w| w.filename
children=move |w: Wallpaper| {
let uri = format!("/wallpapers/{}", w.filename);
view! {
<Marker position=position!(
w.gps.latitude.into(), w.gps.longitude.into()
)>
<Popup>
<a target="_blank" href=uri>
{w.location.precise}
</a>
<br/>
{w.location.broad}
<br/>
<b>{w.date}</b>
</Popup>
</Marker>
}
}
/>
</MapContainer>
</ContentPage>
}
}

View file

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: {
relative: true,
files: ["./src/**/*.rs"],
},
theme: {
extend: {
height: {
almostscreen: "90vh",
halfscreen: "60vh",
}
},
},
plugins: [],
}

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

BIN
public/icons/rubycat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

BIN
public/icons/uqac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
public/phil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

View file

@ -6,7 +6,7 @@ http://philippeloctaux.com
## tech ## tech
- https://rocket.rs - https://leptos.dev
- https://tailwindcss.com - https://tailwindcss.com
## colors ## colors
@ -16,9 +16,10 @@ http://philippeloctaux.com
## wallpapers ## wallpapers
1. place **JPEG** files in `public/wallpapers` and make sure they have exif data (GPS + date) 1. place **JPEG** files in `public/wallpapers` and make sure they have exif data (GPS + date)
2. run `cargo run --bin gen-wallpapers` to generate wallpaper metadata 2. run `cargo run -p gen-wallpapers --example cli -- ./public/wallpapers > ./crates/plcom/wallpapers.json` to generate wallpaper metadata
## icons ## icons
- https://simpleicons.org for brand icons - https://simpleicons.org for brand icons
- https://heroicons.com for the rest - https://heroicons.com for the rest

View file

@ -1,54 +0,0 @@
use rocket::fairing::{self, Fairing};
use rocket::http::{ContentType, Header};
use rocket::{Request, Response};
#[derive(Debug)]
pub struct CacheControl {
duration_secs: u32,
types: Vec<ContentType>,
routes: Vec<&'static str>,
}
impl Default for CacheControl {
fn default() -> Self {
CacheControl {
duration_secs: 60 * 60, // 60 secs * 60 minutes
types: vec![ContentType::CSS, ContentType::JavaScript],
routes: vec!["/wallpapers", "/pub", "/images", "/icons"],
}
}
}
#[rocket::async_trait]
impl Fairing for CacheControl {
fn info(&self) -> fairing::Info {
fairing::Info {
name: "Cache Control",
kind: fairing::Kind::Response,
}
}
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
let mut should_cache = false;
// Check if content type matches
if let Some(content_type) = response.content_type() {
if self.types.contains(&content_type) {
should_cache = true;
}
}
// Check if route matches
self.routes
.iter()
.filter(|s| request.uri().path().starts_with(*s))
.for_each(|_| should_cache = true);
if should_cache {
response.set_header(Header::new(
"Cache-Control",
format!("public, max-age={}", self.duration_secs),
));
}
}
}

View file

@ -1,11 +0,0 @@
const SUFFIX: &str = "Philippe Loctaux";
pub fn title<T: std::fmt::Display>(s: T) -> ::askama::Result<String> {
let prefix = s.to_string();
Ok(if prefix != SUFFIX {
format!("{prefix} - {SUFFIX}")
} else {
prefix
})
}

View file

@ -1,215 +0,0 @@
use dms_coordinates::Bearing;
use exif::{DateTime, Exif, In, Tag};
use std::fs::read_dir;
use std::io::{BufReader, Write};
use std::path::PathBuf;
const WALLPAPERS_PATH: &str = "/public/wallpapers";
const CRATE_PATH: &str = env!("CARGO_MANIFEST_DIR");
const IMPORTS: &str = r#"use crate::types::{Gps, Location, Wallpaper};"#;
#[derive(Debug)]
struct Metadata {
file: String,
date: String,
latitude: f32,
longitude: f32,
}
fn parse_coordinates(exif: &Exif, tag: Tag, r#ref: Tag) -> Option<f32> {
let mut coord = None;
let mut coord_ref = None;
// Parse DMS coordinates
if let Some(field) = exif.get_field(tag, In::PRIMARY) {
match field.value {
exif::Value::Rational(ref vec) if !vec.is_empty() => {
let deg = vec[0].to_f64() as i32;
let min = vec[1].to_f64() as i32;
let sec = vec[2].to_f64();
coord = Some((deg, min, sec));
}
_ => {}
}
}
// Get bearing
if let Some(field) = exif.get_field(r#ref, In::PRIMARY) {
coord_ref = Some(field.display_value().to_string());
}
match (coord, coord_ref) {
(Some((deg, min, sec)), Some(r#ref)) => {
use Bearing::*;
let bearing = match r#ref.as_str() {
"N" => North,
"NE" => NorthEast,
"NW" => NorthWest,
"S" => South,
"SE" => SouthEast,
"SW" => SouthWest,
"E" => East,
"W" => West,
_ => return None,
};
let dms = dms_coordinates::DMS::new(deg, min, sec, bearing);
Some(dms.to_decimal_degrees() as f32)
}
(_, _) => None,
}
}
fn main() {
let mut wallpapers = format!(
"// AUTO GENERATED FILE.\n// PLEASE DO NOT EDIT MANUALLY.\n{IMPORTS}\npub static WALLPAPERS: &[&Wallpaper] = &[\n"
);
// Get list of files
let local_wallpaper_path = format!("{}{}", CRATE_PATH, WALLPAPERS_PATH);
let metadata: Vec<Metadata> = match read_dir(local_wallpaper_path) {
Ok(dir) => {
let mut files = vec![];
for file in dir {
let Ok(file) = file else {
continue;
};
// Get filename
let Ok(filename) = file.file_name().into_string() else {
continue;
};
// Read exif from file
let Ok(file) = std::fs::File::open(file.path()) else {
continue;
};
let mut reader = BufReader::new(file);
let Ok(exif) = exif::Reader::new().read_from_container(&mut reader) else {
continue;
};
// Get GPS coordinates
let latitude = parse_coordinates(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
let longitude = parse_coordinates(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
// Get date
let mut date = None;
if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) {
match field.value {
exif::Value::Ascii(ref vec) if !vec.is_empty() => {
if let Ok(datetime) = DateTime::from_ascii(&vec[0]) {
let datetime = datetime.to_string();
let split: Vec<&str> = datetime.split(' ').collect();
date = split.first().map(|str| str.to_string());
}
}
_ => {}
}
}
match (date, latitude, longitude) {
(Some(date), Some(latitude), Some(longitude)) => files.push(Metadata {
file: format!("/wallpapers/{}", filename),
date,
latitude,
longitude,
}),
(_, _, _) => {
continue;
}
}
}
files
}
Err(_) => vec![],
};
for wallpaper in metadata {
println!("\nProcessing `{}`", wallpaper.file);
let client = reqwest::blocking::Client::new();
let (precise, broad) = match client
.get(format!(
// Documentation: https://nominatim.org/release-docs/develop/api/Reverse/
"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={}&lon={}&zoom=8",
wallpaper.latitude, wallpaper.longitude,
))
.header("User-Agent", "https://philippeloctaux.com")
.send()
.and_then(|data| data.json::<serde_json::Value>())
{
Ok(data) => {
let location = &data["display_name"];
let (precise, broad) = if location.is_string() {
let location = location.to_string();
// Remove first and last characters (the string is wrapped in double quotes '"')
let location = {
let mut chars = location.chars();
chars.next();
chars.next_back();
chars.as_str()
};
println!("Raw location is `{}`", location);
let mut location = location.split(',');
let precise = location.next().unwrap_or("?").to_string();
let mut broad: String =
location.collect::<Vec<&str>>().join(",").trim().to_string();
if broad.is_empty() {
broad.push('?');
}
(precise, broad)
} else {
println!("Failed to find location.");
("?".into(), "?".into())
};
(precise, broad)
}
Err(_) => {
println!("Failed to make API call to get location.");
("?".into(), "?".into())
}
};
println!("Precise location is `{}`", precise);
println!("Broad location is `{}`", broad);
// Construct structs
let gps_struct = format!(
"Gps {{ latitude: {}, longitude: {} }}",
wallpaper.latitude, wallpaper.longitude
);
let location_struct = format!(
"Location {{ precise: \"{}\", broad: \"{}\", gps: {} }}",
precise, broad, gps_struct
);
let wallpaper_struct = format!(
"&Wallpaper {{ file: \"{}\", date: \"{}\", location: {} }},\n",
wallpaper.file, wallpaper.date, location_struct
);
wallpapers.push_str(&wallpaper_struct);
}
wallpapers.push_str("];\n");
// Write string to file
let mut output_wallpapers_path: PathBuf = CRATE_PATH.into();
output_wallpapers_path.push("src/wallpapers.rs");
let mut output_file =
std::fs::File::create(&output_wallpapers_path).expect("file already exists");
output_file
.write_all(wallpapers.as_bytes())
.expect("write dictionary to file");
}

View file

@ -1,78 +0,0 @@
use self::types::*;
use chrono::Datelike;
use rocket::fs::FileServer;
use rocket::{catch, catchers, get, launch, routes};
mod cache;
mod filters;
mod minify;
mod templates;
mod types;
mod wallpapers;
#[launch]
fn rocket() -> _ {
let server = rocket::build()
.mount("/", FileServer::from("public"))
.mount("/", routes![root, email, wallpapers_route])
.register("/", catchers![not_found]);
if cfg!(debug_assertions) {
server
} else {
server
.attach(
rocket_async_compression::CachedCompression::path_suffix_fairing(vec![
// Code
".js".into(),
".css".into(),
// Documents
".pdf".into(),
".txt".into(),
]),
)
.attach(cache::CacheControl::default())
.attach(minify::Minify)
}
}
#[catch(404)]
fn not_found() -> templates::NotFound<'static> {
templates::NotFound {
title: "404 Not found",
year: chrono::Utc::now().year(),
}
}
#[get("/?<wallpaper>")]
fn root(wallpaper: Option<&str>) -> templates::Root<'static> {
templates::Root {
title: "Philippe Loctaux",
year: chrono::Utc::now().year(),
wallpaper: wallpaper
.and_then(Wallpaper::find)
.or_else(Wallpaper::random),
networks: Network::new(),
jobs: Job::new(),
talks: Talk::new(),
friends: Friend::new(),
projects: ProjectKind::new(),
}
}
#[get("/email")]
fn email() -> templates::Email<'static> {
templates::Email {
title: "Email",
year: chrono::Utc::now().year(),
}
}
#[get("/wallpapers")]
fn wallpapers_route() -> templates::Wallpapers<'static> {
templates::Wallpapers {
title: "Wallpapers",
year: chrono::Utc::now().year(),
wallpapers: WALLPAPERS,
}
}

View file

@ -1,39 +0,0 @@
use rocket::fairing::{self, Fairing, Kind};
use rocket::http::ContentType;
use rocket::{Request, Response};
pub struct Minify;
#[rocket::async_trait]
impl Fairing for Minify {
fn info(&self) -> fairing::Info {
fairing::Info {
name: "Minify HTML",
kind: Kind::Response,
}
}
async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) {
if response.content_type() == Some(ContentType::HTML) {
let body = response.body_mut();
if let Ok(original) = body.to_bytes().await {
let cfg = minify_html::Cfg {
// Be HTML spec compliant
do_not_minify_doctype: true,
ensure_spec_compliant_unquoted_attribute_values: true,
keep_spaces_between_attributes: true,
// The rest
keep_closing_tags: true,
keep_html_and_head_opening_tags: true,
minify_css: false,
minify_js: true,
..Default::default()
};
let minified = minify_html::minify(&original, &cfg);
response.set_sized_body(minified.len(), std::io::Cursor::new(minified));
}
}
}
}

View file

@ -1,57 +0,0 @@
use crate::filters;
use crate::types::*;
#[derive(askama::Template)]
#[template(path = "pages/root.html")]
pub struct Root<'a> {
pub title: &'a str,
pub year: i32,
pub wallpaper: Option<&'static &'static Wallpaper>,
pub networks: Vec<Network>,
pub jobs: Vec<Job>,
pub talks: Vec<Talk>,
pub friends: Vec<Friend>,
pub projects: Vec<ProjectKind>,
}
#[derive(rocket::Responder)]
struct RootResponder<'a> {
template: Root<'a>,
}
#[derive(askama::Template)]
#[template(path = "pages/404.html")]
pub struct NotFound<'a> {
pub title: &'a str,
pub year: i32,
}
#[derive(rocket::Responder)]
struct NotFoundResponder<'a> {
template: NotFound<'a>,
}
#[derive(askama::Template)]
#[template(path = "pages/email.html")]
pub struct Email<'a> {
pub title: &'a str,
pub year: i32,
}
#[derive(rocket::Responder)]
struct EmailResponder<'a> {
template: Email<'a>,
}
#[derive(askama::Template)]
#[template(path = "pages/wallpapers.html")]
pub struct Wallpapers<'a> {
pub title: &'a str,
pub year: i32,
pub wallpapers: &'static [&'static Wallpaper],
}
#[derive(rocket::Responder)]
struct WallpapersResponder<'a> {
template: Wallpapers<'a>,
}

View file

@ -1,65 +0,0 @@
use rocket::http::uri::Absolute;
pub mod friend;
pub mod job;
pub mod network;
pub mod project;
pub mod talk;
pub use self::friend::*;
pub use self::job::*;
pub use self::network::*;
pub use self::project::*;
pub use self::talk::*;
pub use crate::wallpapers::WALLPAPERS;
pub struct Logo {
pub file: &'static str,
pub transparent_background: bool,
}
pub struct Link {
pub label: &'static str,
pub uri: Absolute<'static>,
}
pub struct Location {
pub precise: &'static str,
pub broad: &'static str,
pub gps: Gps,
}
pub struct Gps {
pub latitude: f32,
pub longitude: f32,
}
impl std::fmt::Display for Gps {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}, {}]", self.latitude, self.longitude)
}
}
pub struct Wallpaper {
pub file: &'static str,
pub date: &'static str,
pub location: Location,
}
impl Wallpaper {
pub fn random() -> Option<&'static &'static Wallpaper> {
use nanorand::{ChaCha20, Rng};
use std::ops::Range;
let range = Range {
start: 0,
end: WALLPAPERS.len(),
};
WALLPAPERS.get(ChaCha20::new().generate_range(range))
}
pub fn find(filename: &str) -> Option<&'static &'static Wallpaper> {
WALLPAPERS.iter().find(|w| w.file.contains(filename))
}
}

View file

@ -1,84 +0,0 @@
use rocket::http::uri::Absolute;
use rocket::uri;
pub struct Friend {
pub first_name: &'static str,
pub last_name: &'static str,
pub uri: Absolute<'static>,
}
impl Friend {
pub fn initials(&self) -> String {
let first = self
.first_name
.to_uppercase()
.chars()
.next()
.expect("Invalid first name");
let last = self
.last_name
.to_uppercase()
.chars()
.next()
.expect("Invalid last name");
format!("{first}{last}")
}
pub fn domain_name(&self) -> String {
match self.uri.authority() {
Some(authority) => authority.to_string(),
None => self.uri.to_string(),
}
}
pub fn new() -> Vec<Self> {
vec![
Friend {
first_name: "Jamie",
last_name: "Bishop",
uri: uri!("https://jamiebi.shop"),
},
Friend {
first_name: "Ayden",
last_name: "Panhuyzen",
uri: uri!("https://ayden.dev"),
},
Friend {
first_name: "Corbin",
last_name: "Crutchley",
uri: uri!("https://crutchcorn.dev"),
},
Friend {
first_name: "James",
last_name: "Fenn",
uri: uri!("https://jfenn.me"),
},
Friend {
first_name: "Alex",
last_name: "Dueppen",
uri: uri!("https://ajd.sh"),
},
Friend {
first_name: "Peter",
last_name: "Sobolev",
uri: uri!("https://petersoboyejo.com"),
},
Friend {
first_name: "Alexandre",
last_name: "Wagner",
uri: uri!("https://wagnerwave.com"),
},
Friend {
first_name: "Aidan",
last_name: "Follestad",
uri: uri!("https://af.codes"),
},
Friend {
first_name: "Victor",
last_name: "Simon",
uri: uri!("https://simonvictor.com"),
},
]
}
}

View file

@ -1,111 +0,0 @@
use crate::types::Logo;
pub struct Job {
pub company: &'static str,
pub title: &'static str,
pub dates: &'static str,
pub logo: Logo,
pub description: Vec<&'static str>,
pub accomplishments: Vec<&'static str>,
pub technologies: Vec<&'static str>,
}
impl Job {
pub fn new() -> Vec<Self> {
vec![
Job {
company: "Acklio",
title: "Rust developer",
dates: "March 2023 - May 2023",
logo: Logo {
file: "/icons/acklio.png",
transparent_background: true,
},
description: vec!["The first usage of the SCHC framework (RFC 8724) on Rust!"],
accomplishments: vec![
"Creation of Rust bindings of a C library implementing the SCHC framework",
"Demonstration of SCHC with applications in Rust on x86 platform",
"Proof of concept usage of embedded STM32 controllers exclusively in Rust",
"Transmission of knowledge to the technical team",
],
technologies: vec!["Rust", "SCHC", "STM32 controllers", "LoRa", "LoRaWAN"],
},
Job {
company: "Vélorail du Kreiz Breizh",
title: "Freelance developer",
dates: "August 2021 - April 2022",
logo: Logo {
file: "/icons/velorail.png",
transparent_background: true,
},
description: vec![
"Creation of an online booking platform focused on the tourist activity of rail biking (vélorail).",
"During the first 5 months with the platform, 43% of the bookings were made online.",
],
accomplishments: vec![
"Design, UX, booking and payment flow for customers",
"Dashboard for managers with calendar view, manual bookings, slots management",
"Ability to generate invoices, booking recaps for managers",
"Sending emails to customers and managers about bookings",
"Online deployment, maintenance of the service",
],
technologies: vec!["Angular", "NestJS", "GraphQL", "Rust", "Stripe"],
},
Job {
company: "Yaakadev",
title: "Full-Stack developer",
dates: "April 2021 - July 2021",
logo: Logo {
file: "/icons/yaakadev.png",
transparent_background: false,
},
description: vec![
"Maintenance of existing projects for clients",
"Design, development and deployment of multiple projects from scratch:",
],
accomplishments: vec![
"Admin dashboard of a local merchants solution",
"Calendar planning application with filtering and custom views",
"Intranet to upload and download documents",
],
technologies: vec!["NodeJS", "ExpressJS", "Angular", "MongoDB", "CI/CD"],
},
Job {
company: "Epitech",
title: "Teaching assistant (AER)",
dates: "February 2020 - April 2021, September 2022 - February 2023",
logo: Logo {
file: "/icons/epitech.png",
transparent_background: true,
},
description: vec![
"Pedagogical supervision of three classes of students.",
"Conducting educational activities throughout the school year.",
],
accomplishments: vec![
"Start of projects",
"Technical help and guidance",
"Proctoring exams",
"Grading students on their work",
],
technologies: vec!["C", "C++", "Haskell", "Rust", "Web and mobile development"],
},
Job {
company: "Ubiscale",
title: "Embedded developer",
dates: "August 2019 - December 2019",
logo: Logo {
file: "/icons/ubiscale.png",
transparent_background: true,
},
description: vec!["Creation of a home Wifi gateway for an IoT object."],
accomplishments: vec![
"Research, reverse engineering of existing products",
"Design and implementation.",
],
technologies: vec!["C on a ESP8266 controller", "Wi-Fi", "Bluetooth"],
},
]
}
}

View file

@ -1,52 +0,0 @@
use rocket::http::uri::Absolute;
use rocket::uri;
pub enum Icon {
Email,
Github,
Linkedin,
Mastodon,
Telegram,
Twitter,
}
pub struct Network {
pub name: &'static str,
pub uri: Absolute<'static>,
pub icon: Icon,
}
impl Network {
pub fn new() -> Vec<Self> {
vec![
Network {
name: "Twitter",
uri: uri!("https://twitter.com/philippeloctaux"),
icon: Icon::Twitter,
},
Network {
name: "Telegram",
uri: uri!("https://t.me/philippeloctaux"),
icon: Icon::Telegram,
},
Network {
name: "Mastodon",
uri: uri!("https://mastodon.social/@philt3r"),
icon: Icon::Mastodon,
},
Network {
name: "GitHub",
uri: uri!("https://github.com/x4m3"),
icon: Icon::Github,
},
Network {
name: "LinkedIn",
uri: uri!("https://linkedin.com/in/philippeloctaux"),
icon: Icon::Linkedin,
},
Network {
name: "Email",
uri: uri!("https://philippeloctaux.com/email"),
icon: Icon::Email,
},
]
}
}

View file

@ -1,247 +0,0 @@
use crate::types::{Link, Logo};
use rocket::uri;
pub enum Position {
Left,
Right,
}
pub struct Image {
pub file: &'static str,
pub position: Position,
}
pub enum ProjectLink {
Available(Link),
NotAvailable,
}
pub struct ProjectWithImage {
pub name: &'static str,
pub tagline: &'static str,
pub dates: &'static str,
pub description: Vec<&'static str>,
pub accomplishments: Vec<&'static str>,
pub technologies: Vec<&'static str>,
pub link: Option<ProjectLink>,
pub logo: Option<Logo>,
pub image: Image,
}
#[derive(Default)]
pub struct ProjectWithoutImage {
pub name: &'static str,
pub tagline: &'static str,
pub dates: &'static str,
pub description: Vec<&'static str>,
pub accomplishments: Vec<&'static str>,
pub technologies: Vec<&'static str>,
pub link: Option<ProjectLink>,
pub logo: Option<Logo>,
}
pub enum ProjectKind {
WithImage(ProjectWithImage),
WithoutImage((ProjectWithoutImage, ProjectWithoutImage)),
}
impl ProjectKind {
pub fn new() -> Vec<Self> {
vec![
ProjectKind::WithoutImage((
ProjectWithoutImage {
name: "ezidam",
tagline: "Identity and Access Management system",
dates: "January - July 2023",
description: vec![
"A simple identity and access management system for SMEs or personal use.",
"Low maintenance required, easy to deploy and to backup.",
],
accomplishments: vec![
"Users management",
"Roles management",
"Assign users to roles and the other way around",
"OAuth2 / OIDC applications (code flow)",
"Multi-Factor Authentication (TOTP)",
"Password reset (via email or backup token)",
"Simple administration panel",
"Good security measures for users and administrators",
],
technologies: vec![
"Rust",
"SQLite",
"OAuth2 / OIDC",
"TOTP",
"SMTP",
"Docker",
],
logo: Some(Logo {
file: "/icons/ezidam.png",
transparent_background: true,
}),
..Default::default()
},
ProjectWithoutImage {
name: "pass4thewin",
tagline: "Password manager",
dates: "November 2020 - January 2021",
description: vec![
"Port of passwordstore, the standard unix password manager on the Windows platform.",
"Warning! Unfinished command line application, may cause data corruption when using existing passwords.",
],
accomplishments: vec![
"Creation of a store",
"List secrets",
"Decrypt secret",
"Insert or generate secrets",
"Edit existing secrets",
"Synchronisation with git",
"TOTP support",
],
technologies: vec![
"Windows",
"Rust",
"OpenPGP",
"libgit2",
],
link: Some(ProjectLink::Available(Link {
uri: uri!("https://github.com/x4m3/pass4thewin"),
label: "Source code",
})),
..Default::default()
},
)),
ProjectKind::WithImage(
ProjectWithImage{
name: "NaviaRent",
tagline: "Epitech Innovative Project",
dates: "September 2020 - January 2023",
description: vec!["A B2B platform helping rentals of standup paddle boards."],
accomplishments: vec![
"DevOps of all software in the NaviaRent stack",
"Creation of the iOS application",
"Contributions to the Android application",
"Contributions to the backend server",
"Creation and contributions to the web client",
"Server administration, backups",
],
technologies: vec![
"NodeJS",
"Angular",
"Kotlin",
"SwiftUI",
"Docker",
"GitLab CI/CD",
"Raspberry Pi",
"ESP32",
],
logo: Some(Logo {
file: "/icons/naviarent.png",
transparent_background: false,
}),
image: Image {
file: "/images/naviarent.jpg",
position: Position::Right,
},
link: Some(ProjectLink::NotAvailable),
},
),
ProjectKind::WithoutImage((
ProjectWithoutImage{
name: "epitok",
tagline: "Presence system at Epitech",
dates: "June 2020 - September 2020",
description: vec![
"A library and web client to simplify students presence at Epitech.",
"Students are handed a piece of paper with a 6 digits number (called a \"token\") to verify their presence at school events.",
"Teachers use epitok to scan student cards with QR codes on them instead of printing and handing tokens to students.",
],
accomplishments: vec![
"Reverse engineering of a partially documented web API",
"Design, conception",
"User experience",
"Improvements based of usage of the application",
],
technologies: vec![
"Rust",
"HTML",
"Bootstrap",
"jQuery",
"Docker",
],
link: Some(ProjectLink::Available(Link {
uri: uri!("https://github.com/x4m3/epitok"),
label: "Source code",
})),
..Default::default()
},
ProjectWithoutImage{
name: "epi.today",
tagline: "Calendar for Epitech",
dates: "December 2019 - February 2020",
description: vec![
"A viewer of the Epitech intranet calendar.",
"Students and teachers glance at their planning without the need to go on the school's intranet.",
],
accomplishments: vec![],
technologies: vec![
"TypeScript",
"HTML",
"Bootstrap",
"Docker",
],
link: Some(ProjectLink::Available(Link {
uri: uri!("https://github.com/x4m3/epi.today"),
label: "Source code",
})),
..Default::default()
},
)),
ProjectKind::WithImage(
ProjectWithImage{
name: "canvas.place",
tagline: "Timelapse",
dates: "April 2017 - January 2020",
description: vec![
"canvas.place is a shared place to express creativity.",
"People from all over the world share one single canvas to paint on.",
"I created and maintained a timelapse of the virtual canvas."
],
accomplishments: vec![],
technologies: vec!["FFmpeg", "Shell scripting", "nginx"],
logo: Some(Logo {
file: "/icons/canvas.png",
transparent_background: false,
}),
image: Image {
file: "/images/canvas.png",
position: Position::Left,
},
link: Some(ProjectLink::Available(Link {
uri: uri!("https://timelapse.canvas.place"),
label: "Website",
})),
},
)
]
}
}

View file

@ -1,70 +0,0 @@
use crate::types::Link;
use rocket::uri;
pub struct Talk {
pub title: &'static str,
pub date: &'static str,
pub location: &'static str,
pub link: Link,
}
impl Talk {
pub fn new() -> Vec<Self> {
vec![
Talk {
title: "Vim",
date: "February 2023",
location: "Epitech Rennes",
link: Link {
uri: uri!("https://philippeloctaux.com/pub/talks/vim.pdf"),
label: "Slides",
},
},
Talk {
title: "CLion",
date: "March 2021",
location: "Epitech Rennes",
link: Link {
uri: uri!("https://philippeloctaux.com/pub/talks/clion.pdf"),
label: "Slides",
},
},
Talk {
title: "git & devops 2",
date: "February 2021",
location: "Epitech Rennes",
link: Link {
uri: uri!("https://philippeloctaux.com/pub/talks/git-devops2.pdf"),
label: "Slides",
},
},
Talk {
title: "pass4thewin",
date: "February 2021",
location: "Epitech Rennes",
link: Link {
uri: uri!("https://philippeloctaux.com/pub/talks/pass4thewin.pdf"),
label: "Slides",
},
},
Talk {
title: "git & devops",
date: "May 2020",
location: "Epitech Rennes",
link: Link {
uri: uri!("https://philippeloctaux.com/pub/talks/git-devops.pdf"),
label: "Slides",
},
},
Talk {
title: "git gud",
date: "May 2019",
location: "Epitech Rennes",
link: Link {
uri: uri!("https://philippeloctaux.com/pub/talks/git-tek.pdf"),
label: "Slides",
},
},
]
}
}

View file

@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: {
files: ["./templates/**/*.html"],
},
theme: {
extend: {
height: {
almostscreen: '90vh',
halfscreen: '60vh',
}
},
},
plugins: [],
}

View file

@ -1,26 +0,0 @@
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width"/>
<title>{{ title|title }}</title>
<link rel="stylesheet" href="/style.css">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#0c4a6e">
<meta name="msapplication-TileColor" content="#0c4a6e">
<meta name="theme-color" content="#0c4a6e">
<script defer data-domain="philippeloctaux.com" src="https://plausible.y.z.x4m3.rocks/js/script.js"></script>
</head>
<body class="flex flex-col min-h-screen bg-gray-900 text-white">
<div class="flex-grow">
{% block container %}{% endblock %}
</div>
<footer class="bg-black">
<div class="container mx-auto px-4 py-8">
<p>&copy; 2015 - {{ year }} Philippe Loctaux</p>
</div>
</footer>
</body>
</html>

View file

@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block container %}
<main class="container mx-auto px-4 py-16">
<h1 class="text-3xl sm:text-4xl font-bold">{{ title }}</h1>
<div class="mt-8">
{% block content %}{% endblock %}
</div>
</main>
{% endblock %}

View file

@ -1,11 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"></path>
</svg>

Before

Width:  |  Height:  |  Size: 547 B

View file

@ -1,14 +0,0 @@
<svg
class="w-6 h-6 mr-0 sm:mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 556 B

View file

@ -1,9 +0,0 @@
<svg
class="w-6 h-6 fill-current mr-0 sm:mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 887 B

View file

@ -1,13 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 fill-current mr-2"
>
<path
d="M12.232 4.232a2.5 2.5 0 013.536 3.536l-1.225 1.224a.75.75 0 001.061 1.06l1.224-1.224a4 4 0 00-5.656-5.656l-3 3a4 4 0 00.225 5.865.75.75 0 00.977-1.138 2.5 2.5 0 01-.142-3.667l3-3z"
></path>
<path
d="M11.603 7.963a.75.75 0 00-.977 1.138 2.5 2.5 0 01.142 3.667l-3 3a2.5 2.5 0 01-3.536-3.536l1.225-1.224a.75.75 0 00-1.061-1.06l-1.224 1.224a4 4 0 105.656 5.656l3-3a4 4 0 00-.225-5.865z"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 596 B

View file

@ -1,9 +0,0 @@
<svg
class="w-6 h-6 fill-current mr-0 sm:mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 673 B

View file

@ -1,11 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z"
clip-rule="evenodd"></path>
</svg>

Before

Width:  |  Height:  |  Size: 613 B

View file

@ -1,10 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path fill-rule="evenodd"
d="M8.157 2.175a1.5 1.5 0 00-1.147 0l-4.084 1.69A1.5 1.5 0 002 5.251v10.877a1.5 1.5 0 002.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 001.146 0l4.083-1.69A1.5 1.5 0 0018 14.748V3.873a1.5 1.5 0 00-2.073-1.386l-3.51 1.452-4.26-1.763zM7.58 5a.75.75 0 01.75.75v6.5a.75.75 0 01-1.5 0v-6.5A.75.75 0 017.58 5zm5.59 2.75a.75.75 0 00-1.5 0v6.5a.75.75 0 001.5 0v-6.5z"
clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 566 B

View file

@ -1,9 +0,0 @@
<svg
class="w-6 h-6 fill-current mr-0 sm:mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,9 +0,0 @@
<svg
class="w-6 h-6 fill-current mr-0 sm:mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 805 B

View file

@ -1,9 +0,0 @@
<svg
class="w-6 h-6 fill-current mr-0 sm:mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 666 B

View file

@ -1,5 +0,0 @@
{% extends "content.html" %}
{% block content %}
<p>This page could not be found.</p>
{% endblock %}

View file

@ -1,25 +0,0 @@
{% extends "content.html" %}
{% block content %}
<p>Send an email if you want to work with me, propose a project idea, or just to say hi!</p>
<div class="my-4">
<a
href="mailto:helloATphilippeloctauxDOTcom"
class="inline-flex bg-sky-900 hover:bg-sky-700 transition-all duration-200 text-white font-bold py-2 px-4 rounded-xl items-center"
>
<div class="inline-flex items-center">
{% include "icons/email.html" %}
<span class="ml-2 sm:ml-0 text-center">hello at philippeloctaux dot com</span>
</div>
</a>
</div>
<p class="mb-2">
If you want to encrypt your message, I have a
<a href="/pub/pgp-0x69771CD04BA82EC0.txt" class="underline">pgp key</a> at your disposal.
</p>
<p class="mb-2">
I also have a <a href="/keybase.txt" class="underline">Keybase</a> account, but I do not check it often.
</p>
{% endblock %}

View file

@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block container %}
{% include "pages/root/hero-image.html" %}
<main class="container mx-auto px-4 md:px-8 lg:px-16 py-16">
{% include "pages/root/whoami.html" %}
<div class="my-16 space-y-16 md:space-y-32">
{% include "pages/root/www.html" %}
{% include "pages/root/jobs.html" %}
{% include "pages/root/projects.html" %}
{% include "pages/root/talks.html" %}
{% include "pages/root/friends.html" %}
</div>
</main>
{% endblock %}

View file

@ -1,24 +0,0 @@
<div>
<h1 class="text-4xl font-bold mb-4">Friends</h1>
<p class="text-lg">Folks I worked with, or I like what they do.</p>
<ul class="mt-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 sm:gap-4">
{% for friend in friends %}
<li class="py-2">
<a
href={{friend.uri}}
target="_blank"
class="hover:bg-gray-500 transition-all duration-200 flex items-center rounded-lg p-2"
>
<span class="rounded-full flex-shrink-0 mr-4 w-10 h-10 bg-sky-900 text-white flex items-center justify-center text-lg font-medium"
>{{ friend.initials() }}</span
>
<div>
<p class="font-bold">{{ friend.first_name }} {{ friend.last_name }}</p>
<p>{{ friend.domain_name() }}</p>
</div>
</a>
</li>
{% endfor %}
</ul>
</div>

View file

@ -1,8 +0,0 @@
<div class="container mx-auto px-8 py-16 w-full h-full justify-center items-center flex flex-col">
<div class="inline-block backdrop-blur-lg backdrop-brightness-75 rounded-3xl shadow-2xl px-4 py-6 sm:px-8 sm:py-12 space-y-4">
<h1 class="text-3xl sm:text-4xl font-bold">Philippe Loctaux</h1>
<h2 class="sm:text-xl font-semibold">
Developer of all sorts. Epitech alumni, class of 2023.
</h2>
</div>
</div>

View file

@ -1,76 +0,0 @@
<style>
/* Fixed background when scrolling in tailwind
* Disabled on iOS devices
* https://tailwindcss.com/docs/background-attachment
* https://stackoverflow.com/a/60220757/4809297
*/
@supports (-webkit-touch-callout: none) {
/* CSS specific to iOS devices */
#wallpaper {
background-attachment: scroll;
}
}
@supports not (-webkit-touch-callout: none) {
/* CSS for other than iOS devices */
#wallpaper {
background-attachment: fixed;
}
}
</style>
<div class="bg-gradient-to-r from-red-900 via-teal-900 to-fuchsia-900">
{% let wallpaper_uri -%}
{% match wallpaper %}
{% when Some with (wallpaper) %}
{% let wallpaper_uri = wallpaper.file -%}
{% when None %}
{% let wallpaper_uri = "" -%}
{% endmatch %}
<div
id="wallpaper"
class="relative text-white w-full h-almostscreen bg-center bg-cover"
style="background-image: url({{ wallpaper_uri }});"
>
<!-- Content inside -->
{% include "pages/root/hero-content.html" %}
{% match wallpaper %}
{% when Some with (wallpaper) %}
<!-- Exif info -->
<div
class="absolute bottom-3 sm:bottom-5 left-2 sm:left-5 inline-block backdrop-blur-lg backdrop-brightness-75 rounded-xl shadow-2xl p-2 space-y-0.5 sm:space-y-2"
>
<!-- See more -->
<div class="flex">
<div class="inline-flex items-center">
{% include "icons/map.html" %}
<a class="ml-1 text-sm underline" href="/wallpapers">See more!</a>
</div>
</div>
<!-- Location -->
<div class="flex">
<div class="inline-flex items-center">
{% include "icons/location.html" %}
<span class="ml-1 text-sm">
{{ wallpaper.location.precise }}<span class="hidden md:inline">, {{ wallpaper.location.broad }}</span>
</span>
</div>
</div>
<!-- Date -->
<div class="flex">
<div class="inline-flex items-center">
{% include "icons/calendar.html" %}
<span class="ml-1 text-sm">{{ wallpaper.date }}</span>
</div>
</div>
</div>
{% when None %}
{% endmatch %}
</div>
</div>

View file

@ -1,64 +0,0 @@
<div>
<h1 class="text-4xl font-bold mb-4">Professional Experiences</h1>
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{% for job in jobs %}
<div class="w-full rounded-2xl bg-sky-950">
<div class="p-6 justify-between h-full">
<!-- Header -->
<div class="flex flex-col">
<div class="flex flex-row">
{% let css -%}
{# If css updates make sure to update in both places! #}
{% if job.logo.transparent_background == true -%}
{% let css = "h-16 w-16 rounded-xl mr-4 p-2 bg-white" -%}
{% else -%}
{% let css = "h-16 w-16 rounded-xl mr-4" -%}
{% endif -%}
<img loading="lazy" src="{{ job.logo.file }}" alt="{{ job.company }} logo" class="{{ css }}">
<div class="flex flex-col justify-evenly">
<div class="text-xl md:text-2xl font-semibold">{{ job.company }}</div>
<div class="text-xs md:text-sm">{{ job.dates }}</div>
</div>
</div>
<p class="text-xl md:text-2xl font-semibold my-4">{{ job.title }}</p>
</div>
<!-- Content -->
<div class="space-y-2">
<!-- Description -->
{% for desc in job.description %}
<p>{{ desc }}</p>
{% endfor %}
<!-- Accomplishments -->
<div>
<ul class="list-disc mt-6">
{% for accomplishment in job.accomplishments %}
<li class="ml-5">{{ accomplishment }}</li>
{% endfor %}
</ul>
</div>
<!-- Technologies -->
<div>
<div class="text-xl font-semibold mt-8 mb-2">
Technologies
</div>
<ul>
{% for technology in job.technologies %}
<li>{{ technology }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>

View file

@ -1,108 +0,0 @@
{% let css_image_position -%}
{% let css_image_position_corner -%}
{# If css updates make sure to update in both places! #}
{% match project.image.position %}
{% when Position::Left %}
{% let css_image_position = "2xl:flex items-stretch justify-between" -%}
{% let css_image_position_corner = "flex w-full 2xl:w-1/2 grow rounded-t-2xl object-cover 2xl:rounded-tr-none 2xl:rounded-l-2xl" -%}
{% when Position::Right %}
{% let css_image_position = "2xl:flex items-stretch justify-between 2xl:flex-row-reverse" -%}
{% let css_image_position_corner = "flex w-full 2xl:w-1/2 grow rounded-t-2xl object-cover 2xl:rounded-tl-none 2xl:rounded-r-2xl" -%}
{% endmatch %}
<div class="w-full rounded-2xl bg-pink-950 {{ css_image_position }}">
<!-- Image on the side -->
<img loading="lazy" src="{{ project.image.file }}" alt="{{ project.name }} image" class="{{ css_image_position_corner }}">
<div class="p-6 flex flex-col grow-0 justify-between h-full">
<!-- Header -->
<div class="flex flex-col">
<div class="flex flex-row">
{% match project.logo %}
{% when Some with (logo) %}
{% let css_logo -%}
{# If css updates make sure to update in both places! #}
{% if logo.transparent_background == true -%}
{% let css_logo = "h-16 w-16 rounded-xl mr-4 p-2 bg-white" -%}
{% else -%}
{% let css_logo = "h-16 w-16 rounded-xl mr-4" -%}
{% endif -%}
<img loading="lazy" src="{{ logo.file }}" alt="{{ project.name }} logo" class="{{ css_logo }}">
{% when None %}
{% endmatch %}
<div class="flex flex-col justify-evenly">
<div class="text-xl md:text-2xl font-semibold">{{ project.name }}</div>
<div class="text-xs md:text-sm">{{ project.dates }}</div>
</div>
</div>
<p class="text-xl md:text-2xl font-semibold my-4">{{ project.tagline }}</p>
</div>
<!-- Content -->
<div class="space-y-2">
<!-- Description -->
{% for desc in project.description %}
<p>{{ desc }}</p>
{% endfor %}
<!-- Accomplishments -->
<div>
<ul class="list-disc mt-6">
{% for accomplishment in project.accomplishments %}
<li class="ml-5">{{ accomplishment }}</li>
{% endfor %}
</ul>
</div>
<!-- Technologies -->
<div>
<div class="text-xl font-semibold mt-8 mb-2">
Technologies
</div>
<ul>
{% for technology in project.technologies %}
<li>{{ technology }}</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Link with label -->
{% match project.link %}
{% when Some with (link) %}
{% match link %}
{% when ProjectLink::Available(project_link) %}
<a
href="{{ project_link.uri }}"
target="_blank"
class="mt-4 inline-flex max-w-fit bg-transparent hover:bg-sky-700 text-white font-semibold py-1.5 px-4 rounded-xl items-center border border-white hover:border-transparent transition-all duration-200"
>
<div class="inline-flex items-center">
{% include "icons/link.html" %}
<span>{{ project_link.label }}</span>
</div>
</a>
<!-- Link is not available -->
{% when ProjectLink::NotAvailable %}
<span class="mt-4 cursor-not-allowed inline-flex max-w-fit bg-gray-400 text-gray-600 font-semibold py-1.5 px-4 rounded-xl items-center">
No longer available
</span>
{% endmatch %}
<!-- No link -->
{% when None %}
{% endmatch %}
</div>
</div>

View file

@ -1,91 +0,0 @@
<div class="w-full rounded-2xl bg-pink-950">
<div class="p-6 justify-between h-full">
<!-- Header -->
<div class="flex flex-col">
<div class="flex flex-row">
{% match project.logo %}
{% when Some with (logo) %}
{% let css_logo -%}
{# If css updates make sure to update in both places! #}
{% if logo.transparent_background == true -%}
{% let css_logo = "h-16 w-16 rounded-xl mr-4 p-2 bg-white" -%}
{% else -%}
{% let css_logo = "h-16 w-16 rounded-xl mr-4" -%}
{% endif -%}
<img loading="lazy" src="{{ logo.file }}" alt="{{ project.name }} logo" class="{{ css_logo }}">
{% when None %}
{% endmatch %}
<div class="flex flex-col justify-evenly">
<div class="text-xl md:text-2xl font-semibold">{{ project.name }}</div>
<div class="text-xs md:text-sm">{{ project.dates }}</div>
</div>
</div>
<p class="text-xl md:text-2xl font-semibold my-4">{{ project.tagline }}</p>
</div>
<!-- Content -->
<div class="space-y-2">
<!-- Description -->
{% for desc in project.description %}
<p>{{ desc }}</p>
{% endfor %}
<!-- Accomplishments -->
<div>
<ul class="list-disc mt-6">
{% for accomplishment in project.accomplishments %}
<li class="ml-5">{{ accomplishment }}</li>
{% endfor %}
</ul>
</div>
<!-- Technologies -->
<div>
<div class="text-xl font-semibold mt-8 mb-2">
Technologies
</div>
<ul>
{% for technology in project.technologies %}
<li>{{ technology }}</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Link with label -->
{% match project.link %}
{% when Some with (link) %}
{% match link %}
{% when ProjectLink::Available(project_link) %}
<a
href="{{ project_link.uri }}"
target="_blank"
class="mt-4 inline-flex max-w-fit bg-transparent hover:bg-sky-700 text-white font-semibold py-1.5 px-4 rounded-xl items-center border border-white hover:border-transparent transition-all duration-200"
>
<div class="inline-flex items-center">
{% include "icons/link.html" %}
<span>{{ project_link.label }}</span>
</div>
</a>
<!-- Link is not available -->
{% when ProjectLink::NotAvailable %}
<span class="mt-4 cursor-not-allowed inline-flex max-w-fit bg-gray-400 text-gray-600 font-semibold py-1.5 px-4 rounded-xl items-center">
No longer available
</span>
{% endmatch %}
<!-- No link -->
{% when None %}
{% endmatch %}
</div>
</div>

View file

@ -1,23 +0,0 @@
<div>
<h1 class="text-4xl font-bold mb-4">Projects</h1>
<div class="mt-4 space-y-4">
{% for project in projects %}
{% match project %}
{% when ProjectKind::WithoutImage((project1, project2)) %}
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
{% let project = project1 -%}
{% include "pages/root/project-without-image.html" %}
{% let project = project2 -%}
{% include "pages/root/project-without-image.html" %}
</div>
{% when ProjectKind::WithImage(project) %}
{% include "pages/root/project-with-image.html" %}
{% endmatch %}
{% endfor %}
</div>
</div>

View file

@ -1,46 +0,0 @@
<div>
<h1 class="text-4xl font-bold mb-4">Talks</h1>
<p class="text-lg">
Giving a talk is the opportunity to share what I know, and helps me
reduce my fear of public speaking.
</p>
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 place-content-center">
{% for talk in talks %}
<div class="rounded-2xl w-full bg-teal-950 p-6">
<!-- Title -->
<h3 class="text-xl font-semibold mb-4">{{talk.title}}</h3>
<!-- Date -->
<div class="flex">
<div class="inline-flex items-center">
{% include "icons/calendar.html" %}
<span class="ml-2">{{talk.date}}</span>
</div>
</div>
<!-- Location -->
<div class="flex">
<div class="inline-flex items-center">
{% include "icons/location.html" %}
<span class="ml-2">{{talk.location}}</span>
</div>
</div>
<!-- Link with label -->
<a
href={{talk.link.uri}}
target="_blank"
class="mt-4 inline-flex bg-transparent hover:bg-sky-700 text-white font-semibold py-1.5 px-4 rounded-xl items-center border border-white hover:border-transparent transition-all duration-200"
>
<div class="inline-flex items-center">
{% include "icons/link.html" %}
<span>{{talk.link.label}}</span>
</div>
</a>
</div>
{% endfor %}
</div>
</div>

View file

@ -1,39 +0,0 @@
<div class="md:flex md:flex-row-reverse items-center">
<div class="md:w-1/2 mb-4 md:mb-0">
<img
src="/pub/phil.png"
alt="Phil"
class="rounded-3xl bg-sky-900 h-36 w-36 md:mx-auto md:h-56 md:w-56 lg:h-64 lg:w-64 mb-2 md:mb-0"
/>
</div>
<div class="md:w-1/2">
<h1 class="text-4xl font-bold mb-4">About Phil</h1>
<h2 class="text-2xl font-semibold mb-4">Developer of all sorts</h2>
<div class="text-lg space-y-6">
<p>
I got into computer science by learning about the Linux kernel and administrating servers.
</p>
<p>
After high school, I became a student at Epitech and learned to
tackle technical concepts and apply them quickly by working
on small projects.
</p>
<p>
During my studies at Epitech, I had the opportunity to be a
teacher. My role was to assist students with technical problems
in their projects.
</p>
<p>
Now I have experience in software engineering, full-stack web
and mobile development, system administration and CI/CD, as well
as embedded development.
</p>
<p>
My goal is to use my knowledge and experience to make software
helping its users accomplish their needs.
</p>
</div>
</div>
</div>

View file

@ -1,39 +0,0 @@
<div class="grid grid-cols-3 lg:grid-cols-6 gap-4 place-content-center">
{% for network in networks %}
<div class="w-full h-auto md:w-auto">
<div class="text-center">
<a
target="_blank"
href="{{ network.uri }}"
class="inline-flex bg-sky-900 hover:bg-sky-700 transition-all duration-200 text-white font-bold py-2 px-4 rounded-xl items-center"
>
{% match network.icon %}
{% when Icon::Email %}
{% include "icons/email.html" %}
{% when Icon::Github %}
{% include "icons/github.html" %}
{% when Icon::Linkedin %}
{% include "icons/linkedin.html" %}
{% when Icon::Mastodon %}
{% include "icons/mastodon.html" %}
{% when Icon::Telegram %}
{% include "icons/telegram.html" %}
{% when Icon::Twitter %}
{% include "icons/twitter.html" %}
{% endmatch %}
<div class="inline-flex items-center">
<span class="hidden sm:inline">{{ network.name }}</span>
</div>
</a>
</div>
</div>
{% endfor %}
</div>

View file

@ -1,27 +0,0 @@
{% extends "content.html" %}
{% block content %}
<p class="mb-2">Pictures I took around the world</p>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<div id="map" class="w-full h-halfscreen"></div>
<script>
let map = L.map('map').setView([0, 0], 1);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
</script>
{% for wallpaper in wallpapers %}
<script>
var marker = L.marker({{ wallpaper.location.gps }}).addTo(map);
marker.bindPopup(`<a target="_blank" href="{{ wallpaper.file }}">{{ wallpaper.location.precise }}</a><br>{{ wallpaper.location.broad }}<br><b>{{ wallpaper.date }}</b>`);
</script>
{% endfor %}
{% endblock %}