From da05adede1eb3b139924bbd40f13bd79369c418a Mon Sep 17 00:00:00 2001 From: Yasser Boulayoune Date: Thu, 8 Oct 2026 13:19:00 +0200 Subject: [PATCH] =?UTF-8?q?Pages=20Comp=C3=A9tences,=20Contact=20et=20Qui?= =?UTF-8?q?=20suis-je?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++ front-page.php | 109 ++++++++++++++ inc/error-template.php | 72 +++++++++ inc/errors.php | 51 +++++++ inc/profile.php | 323 +++++++++++++++++++++++++++++++++++++++++ inc/proofs.php | 210 +++++++++++++++++++++++++++ inc/seasons.php | 222 ++++++++++++++++++++++++++++ page-competences.php | 173 ++++++++++++++++++++++ page-realisation.php | 17 +++ single.php | 50 +++++++ 10 files changed, 1242 insertions(+) create mode 100644 README.md create mode 100644 front-page.php create mode 100644 inc/error-template.php create mode 100644 inc/errors.php create mode 100644 inc/profile.php create mode 100644 inc/proofs.php create mode 100644 inc/seasons.php create mode 100644 page-competences.php create mode 100644 page-realisation.php create mode 100644 single.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4934d0 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# yb2026 + +Thème WordPress sur mesure du portfolio : sans page builder, meta-boxes natives, thèmes de saison et mode clair / sombre. + +## Stack + +- PHP (thème WordPress classique, sans constructeur de pages) +- JavaScript vanilla (orbe en canvas, transitions, curseur) +- CSS à jetons pour le mode clair / sombre + +## Installation + +``` +Copier le dossier dans `wp-content/themes/`, puis l'activer dans Apparence → Thèmes. +``` diff --git a/front-page.php b/front-page.php new file mode 100644 index 0000000..2d3089b --- /dev/null +++ b/front-page.php @@ -0,0 +1,109 @@ +ID, '_yb_year', true); }, $projects)); +preg_match_all('/\d{4}/', implode(' ', $years), $yy); +$span = $yy[0] ? min($yy[0]) . ' / ' . max($yy[0]) : date('Y'); +$sub = mb_strtoupper(yb2026_id('casquettes') . ' · ' . yb2026_id('localisation')); +$kicker = mb_strtoupper(yb2026_id('poste') . ' @ ' . yb2026_id('entreprise')); +?> + + + + + + + + +
+
PORTFOLIO · ÉDITION
+
+
+

+ YASSER + BOULAYOUNE +

+
+
+ + +
+ + +
+
+
+
+

TRAVAUX.

+
SELECTED WORKS ·
+
+
+
( SCROLL · LA SECTION GLISSE → )
+
01 /
+
+
+ +
+
+ + +
+ +
+

CODE.
SERVEURS.
RÉSEAUX.

+
+
+

+
+
▸ WEB · PHP, WordPress, Symfony, JS
+
▸ SYS · Debian, Docker, virtualisation
+
▸ RÉSEAU · VLAN, DNS, DHCP, WireGuard
+
▸ OPS · supervision, backups, TLS
+
+
+ +
+ + + + + +
+
+ TRAVAILLONS
ENSEMBLE →
+
+ + diff --git a/inc/error-template.php b/inc/error-template.php new file mode 100644 index 0000000..9263e81 --- /dev/null +++ b/inc/error-template.php @@ -0,0 +1,72 @@ + + + + + + + +<?php echo esc_html($yb_code . ' · ' . $yb_title); ?> · Yasser Boulayoune + + + + + +
YB©2026YASSER BOULAYOUNE · PORTFOLIO
+
+ +

ERREUR

+ +

+

+

REQUÊTE/→

+
+ ACCUEIL → + + CONTACT +
+
+ + + + diff --git a/inc/errors.php b/inc/errors.php new file mode 100644 index 0000000..48dc5cf --- /dev/null +++ b/inc/errors.php @@ -0,0 +1,51 @@ + 'Requête invalide', 401 => 'Authentification requise', 403 => 'Accès interdit', 404 => 'Page introuvable', + 405 => 'Méthode non autorisée', 410 => 'Page supprimée', 429 => 'Trop de requêtes', 500 => 'Erreur interne', 503 => 'Maintenance en cours'); +} + +/* Affiche la page d'erreur (code HTTP, titre, message HTML déjà sûr) puis s'arrête */ +function yb2026_render_error($code, $title, $msg_html) +{ + $yb_code = (int) $code ?: 500; + $yb_title = $title !== '' ? $title : (yb2026_error_titles()[$yb_code] ?? 'Erreur'); + $yb_msg = $msg_html; + if (!headers_sent()) { + status_header($yb_code); + nocache_headers(); + header('Content-Type: text/html; charset=utf-8'); + header('X-Robots-Tag: noindex, nofollow'); + } + // on vide les tampons de sortie (dont celui d'Autoptimize) : la page sort à l'identique des pages Apache / NPM + while (ob_get_level() > 0) ob_end_clean(); + include __DIR__ . '/error-template.php'; + exit; +} + +add_filter('wp_die_handler', function ($handler) { + if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST) || (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) + || (isset($GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'wp-login.php')) { + return $handler; + } + return function ($message, $title = '', $args = array()) { + $args = wp_parse_args($args, array('response' => 500)); + if (is_wp_error($message)) { + $data = $message->get_error_data(); + if (is_array($data) && !empty($data['status'])) $args['response'] = $data['status']; + $message = implode('
', array_map('esc_html', $message->get_error_messages())); + } + $code = (int) $args['response']; + // le titre WordPress par défaut (« Erreur WordPress ») n'apporte rien : on garde un titre lisible + $t = is_string($title) && $title !== '' && stripos($title, 'WordPress') === false ? wp_strip_all_tags($title) : ''; + $message = trim(preg_replace('#]*>#i', ' ', (string) $message)); // le modèle fournit déjà le paragraphe + yb2026_render_error($code, $t, wp_kses_post($message)); + }; +}); diff --git a/inc/profile.php b/inc/profile.php new file mode 100644 index 0000000..8450b20 --- /dev/null +++ b/inc/profile.php @@ -0,0 +1,323 @@ + texte), yb_parcours, yb_skills, yb_services (listes). + */ + +/** Valeurs par défaut (= contenu de référence du design v3). */ +function yb2026_profile_defaults() +{ + return array( + 'yb_identity' => array( + 'poste' => 'Chargé support logiciel MCO', + 'entreprise' => 'SeipraScore', + 'casquettes' => 'Développeur web · Admin sys & réseaux', + 'localisation' => 'Région lyonnaise', + 'region' => 'Auvergne-Rhône-Alpes', + 'ville_schema' => 'Lyon', + 'email' => 'contact@exemple.fr', + 'telephone' => '06 00 00 00 00', + 'mobilite' => 'Permis B', + 'hero_texte' => 'Support logiciel et maintien en conditions opérationnelles chez SeipraScore. Développeur web et admin Linux à côté, en continu.', + 'apropos' => '', + 'intro' => '', + 'accroche' => 'Un projet ? Une question ? Un échange ?', + ), + 'yb_parcours' => array(), + 'yb_skills' => array(), + 'yb_services' => array(), + ); +} + +/** Description des champs de chaque répéteur : clé => [libellé, type]. */ +function yb2026_profile_repeaters() +{ + return array( + 'yb_parcours' => array( + 'title' => 'Parcours', + 'help' => 'Du plus récent au plus ancien. La date et le lieu s\'affichent en majuscules automatiquement.', + 'fields' => array('date' => array('Période', 'text'), 'titre' => array('Intitulé', 'text'), 'lieu' => array('Lieu / établissement', 'text'), 'desc' => array('Description', 'textarea')), + ), + 'yb_skills' => array( + 'title' => 'Compétences techniques', + 'help' => 'Barres de la page Qui suis-je. Pourcentage = remplissage de la barre (0 à 100).', + 'fields' => array('name' => array('Compétence', 'text'), 'level' => array('Niveau', 'text'), 'pct' => array('%', 'number')), + ), + 'yb_services' => array( + 'title' => 'Services (page Compétences)', + 'help' => 'Cartes numérotées ( 01 ), ( 02 )… dans l\'ordre de la liste.', + 'fields' => array('titre' => array('Titre', 'text'), 'desc' => array('Description', 'textarea')), + ), + ); +} + +/** Libellés des champs d'identité. */ +function yb2026_identity_fields() +{ + return array( + 'poste' => array('Poste', 'text', 'Kicker du hero, fiche Qui suis-je, données structurées.'), + 'entreprise' => array('Entreprise', 'text', 'Badge de la nav (« @ ENTREPRISE »), hero, schema.org worksFor.'), + 'casquettes' => array('Casquettes', 'text', 'Sous-titre du hero, avant la localisation.'), + 'localisation' => array('Localisation', 'text', 'Volontairement large : pas de ville précise.'), + 'region' => array('Région', 'text', 'Ajoutée après la localisation dans le footer, le menu mobile et le contact.'), + 'ville_schema' => array('Ville (SEO uniquement)', 'text', 'Utilisée seulement dans les données structurées schema.org, jamais affichée.'), + 'email' => array('E-mail', 'text', ''), + 'telephone' => array('Téléphone', 'text', ''), + 'mobilite' => array('Mobilité', 'text', ''), + 'hero_texte' => array('Texte du hero (à droite)', 'textarea', 'Accueil, paragraphe au-dessus de « SCROLL TO EXPLORE ».'), + 'apropos' => array('Texte « À propos » de l\'accueil', 'textarea', 'Section CODE. SERVEURS. RÉSEAUX. Les mots entre *astérisques* passent en lime.'), + 'intro' => array('Intro de la page Qui suis-je', 'textarea', ''), + 'accroche' => array('Accroche du bloc contact', 'text', 'Au-dessus de « TRAVAILLONS ENSEMBLE ».'), + ); +} + +/** Lecture d'une option du profil, fusionnée avec les valeurs par défaut. */ +function yb2026_profile($option) +{ + $defaults = yb2026_profile_defaults(); + $value = get_option($option, null); + if ($option === 'yb_identity') { + return array_merge($defaults['yb_identity'], is_array($value) ? array_filter($value, 'strlen') : array()); + } + return is_array($value) ? $value : $defaults[$option]; +} + +/** Raccourci : un champ d'identité. */ +function yb2026_id($key) +{ + $id = yb2026_profile('yb_identity'); + return isset($id[$key]) ? $id[$key] : ''; +} + +/** Localisation complète : « Région lyonnaise, Auvergne-Rhône-Alpes ». */ +function yb2026_location_long() +{ + $loc = yb2026_id('localisation'); + $reg = yb2026_id('region'); + return $reg ? $loc . ', ' . $reg : $loc; +} + +/** « 06 00 00 00 00 » → « tel:+33600000000 ». */ +function yb2026_tel_href($tel) +{ + $digits = preg_replace('/\D/', '', (string) $tel); + return 'tel:' . (strpos($digits, '0') === 0 ? '+33' . substr($digits, 1) : '+' . $digits); +} + +/** Texte avec *mots* mis en lime (échappé). */ +function yb2026_lime_marks($text) +{ + return preg_replace('/\*([^*]+)\*/u', '$1', esc_html($text)); +} + +/* ── Admin ──────────────────────────────────────────────────────────── */ + +add_action('admin_menu', function () { + add_menu_page('Profil', 'Profil', 'edit_pages', 'yb-profil', 'yb2026_profile_page', 'dashicons-id-alt', 3); +}); + +function yb2026_profile_page() +{ + $identity = yb2026_profile('yb_identity'); + ?> +
+

Profil

+

Ces informations alimentent tout le site : hero, nav, Qui suis-je, Compétences, Contact, footer et données SEO.

+

Profil enregistré.

+ +
+ + + +

Situation et coordonnées

+ + $f) : ?> + + + + + + + + $rep) : + $rows = yb2026_profile($opt); ?> +

+

+ + + + + + + $row) yb2026_profile_row($opt, $rep['fields'], $i, $row); ?> + +
+ +

+ + + +
+
+ + + ). */ +function yb2026_profile_row($opt, $fields, $i, $row) +{ + echo ''; + foreach ($fields as $key => $f) { + $name = $opt . '[' . $i . '][' . $key . ']'; + $val = isset($row[$key]) ? $row[$key] : ''; + echo ''; + if ($f[1] === 'textarea') { + echo ''; + } else { + $extra = $f[1] === 'number' ? ' min="0" max="100"' : ''; + echo ''; + } + echo ''; + } + echo '' + . ' ' + . ' ' + . '' + . ''; +} + +add_action('admin_post_yb_save_profile', function () { + if (!current_user_can('edit_pages')) wp_die('Accès refusé.'); + check_admin_referer('yb_save_profile'); + + $identity = array(); + $raw = isset($_POST['yb_identity']) ? (array) wp_unslash($_POST['yb_identity']) : array(); + foreach (yb2026_identity_fields() as $key => $f) { + $val = isset($raw[$key]) ? $raw[$key] : ''; + $identity[$key] = $f[1] === 'textarea' ? sanitize_textarea_field($val) : sanitize_text_field($val); + } + update_option('yb_identity', $identity, false); + + foreach (yb2026_profile_repeaters() as $opt => $rep) { + $rows = isset($_POST[$opt]) ? (array) wp_unslash($_POST[$opt]) : array(); + $clean = array(); + foreach ($rows as $row) { + $item = array(); + foreach ($rep['fields'] as $key => $f) { + $val = isset($row[$key]) ? $row[$key] : ''; + if ($f[1] === 'number') $item[$key] = max(0, min(100, (int) $val)); + elseif ($f[1] === 'textarea') $item[$key] = sanitize_textarea_field($val); + else $item[$key] = sanitize_text_field($val); + } + /* Ligne vide ignorée */ + if (implode('', array_map('strval', array_filter($item, 'is_string')))) $clean[] = $item; + } + update_option($opt, $clean, false); + } + + wp_safe_redirect(admin_url('admin.php?page=yb-profil&updated=1')); + exit; +}); + +/* ── Données structurées schema.org Person ───────────────────────────── + SEOPress émet déjà un Person minimal avec le même @id : les deux nœuds + fusionnent, celui-ci ajoute poste, employeur et zone géographique. */ +add_action('wp_head', function () { + $id = yb2026_profile('yb_identity'); + $person = array( + '@context' => 'https://schema.org', + '@type' => 'Person', + '@id' => home_url('/#person'), + 'name' => 'Yasser Boulayoune', + 'url' => home_url('/'), + 'jobTitle' => $id['poste'], + 'worksFor' => array('@type' => 'Organization', 'name' => $id['entreprise']), + 'address' => array('@type' => 'PostalAddress', 'addressLocality' => $id['ville_schema'], 'addressRegion' => $id['region'], 'addressCountry' => 'FR'), + 'knowsAbout' => array('Support logiciel', 'MCO', 'Développement web', 'Administration systèmes et réseaux', 'Linux', 'Docker', 'WordPress', 'Symfony'), + ); + echo '' . "\n"; +}, 20); + +/* ── Protection anti-collecte de l'email et du téléphone ─────────────── + Dans le HTML, ces valeurs n'apparaissent jamais en clair : elles sont + encodées (ROT13 → inversion → base64) et décodées en JS au premier geste + d'un visiteur humain (voir YBProtect dans effects.js). Un robot qui lit + le HTML brut ne trouve ni « @ », ni « mailto: », ni numéro. */ + +/** Encode une chaîne (décodage JS : atob → inversion → ROT13). */ +function yb2026_obf($s) +{ + return base64_encode(strrev(str_rot13((string) $s))); +} + +/** Texte protégé : affiché « •••• » tant que le JS ne l'a pas décodé. */ +function yb2026_pv($value) +{ + return '' . str_repeat('•', 8) . ''; +} + +/** Attributs d'un lien protégé (href décodé au premier geste). */ +function yb2026_pv_href($href) +{ + return 'href="#" data-pv-href="' . esc_attr(yb2026_obf($href)) . '"'; +} + +/** Contenu des pages (mentions légales…) : email et téléphone du profil protégés automatiquement. */ +add_filter('the_content', function ($html) { + $email = yb2026_id('email'); + $tel = yb2026_id('telephone'); + if ($email) { + /* x → lien protégé */ + $html = preg_replace_callback('#]*?)href=(["\'])mailto:' . preg_quote($email, '#') . '\2([^>]*)>(.*?)#is', function ($m) use ($email) { + $inner = str_ireplace($email, yb2026_pv($email), $m[4]); + return '' . $inner . ''; + }, $html); + /* Email en texte brut restant (hors attributs) */ + $html = preg_replace_callback('#(>[^<]*?)' . preg_quote($email, '#') . '#i', function ($m) use ($email) { return $m[1] . yb2026_pv($email); }, $html); + } + if ($tel) { + $digits = preg_replace('/\D/', '', $tel); + $loose = implode('[\s.\-]?', str_split($digits)); // « 06 00 00 00 00 », « 06.00… », « 0600000000 » + $html = preg_replace_callback('#(>[^<]*?)' . $loose . '#', function ($m) use ($tel) { return $m[1] . yb2026_pv($tel); }, $html); + } + return $html; +}, 30); diff --git a/inc/proofs.php b/inc/proofs.php new file mode 100644 index 0000000..b8a2263 --- /dev/null +++ b/inc/proofs.php @@ -0,0 +1,210 @@ + nom, 'items' => [ID, ID…] ]. + * Les items sont des réalisations ou des articles ; le premier est la carte + * de devant dans le paquet. Nom court d'un article sur les cartes : méta `_yb_short_label`. + * + * Admin : Profil → Compétences (créer / renommer / réordonner / supprimer + * des compétences, y ajouter des réalisations et des articles). + */ + +/** Liste prête pour le gabarit : [ nom => [ [post, kind, name, tag, thumb, wipe], … ] ]. */ +function yb2026_get_proofs() +{ + $rows = get_option('yb_proofs', array()); + $out = array(); + foreach ((array) $rows as $row) { + $links = array(); + foreach ((array) ($row['items'] ?? array()) as $id) { + $p = get_post((int) $id); + if (!$p || $p->post_status !== 'publish' || !in_array($p->post_type, array('realisation', 'post'), true)) continue; + $is_post = $p->post_type === 'post'; + $short = $is_post ? (string) get_post_meta($p->ID, '_yb_short_label', true) : ''; + $cat = $is_post ? get_the_category($p->ID) : array(); + $links[] = array( + 'post' => $p, + 'kind' => $is_post ? 'article' : 'projet', + 'name' => $short !== '' ? $short : yb2026_short_title($p), + 'tag' => $is_post ? 'ARTICLE · ' . mb_strtoupper($cat ? $cat[0]->name : 'BLOG') : (string) get_post_meta($p->ID, '_yb_tag', true), + 'thumb' => get_the_post_thumbnail_url($p, 'large'), + 'wipe' => $is_post ? 'LECTURE' : mb_strtoupper(yb2026_short_title($p)), + ); + } + if ($links && !empty($row['skill'])) $out[$row['skill']] = $links; + } + return $out; +} + +/* ── Admin ──────────────────────────────────────────────────────────── */ + +add_action('admin_menu', function () { + add_submenu_page('yb-profil', 'Compétences', 'Compétences', 'edit_pages', 'yb-competences', 'yb2026_proofs_page'); +}); + +function yb2026_proofs_page() +{ + $rows = (array) get_option('yb_proofs', array()); + $reals = get_posts(array('post_type' => 'realisation', 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC')); + $arts = get_posts(array('post_type' => 'post', 'post_status' => 'publish', 'numberposts' => -1)); + $label = function ($p) { + $s = $p->post_type === 'post' ? (string) get_post_meta($p->ID, '_yb_short_label', true) : ''; + return ($p->post_type === 'post' ? 'Article · ' : 'Réalisation · ') . ($s !== '' ? $s : yb2026_short_title($p)); + }; + $all = array(); + foreach (array_merge($reals, $arts) as $p) $all[$p->ID] = $label($p); + + /* Contenus rattachés à aucune compétence : signalés en haut de page */ + $used = array(); + foreach ($rows as $r) foreach ((array) ($r['items'] ?? array()) as $id) $used[(int) $id] = true; + $orphans = array_diff_key($all, $used); + + $options = function () use ($reals, $arts, $label) { + $h = ''; + foreach ($reals as $p) $h .= ''; + $h .= ''; + foreach ($arts as $p) $h .= ''; + return $h . ''; + }; + $chip = function ($id, $row) use ($all) { + return '' + . esc_html($all[$id] ?? ('#' . $id)) . ' '; + }; + ?> +
+

Compétences

+

Chaque compétence apparaît sur la page Compétences, dans « Chaque compétence a son projet ». Le premier contenu d'une compétence est la carte de devant ; avec plusieurs contenus, un clic ouvre le menu circulaire.

+

Compétences enregistrées.

+ +

Pas encore rattachés :

+ + +
+ + + +
+ $r) : ?> +
+
+ + + + +
+
+ +
+ +
+ +

+ +

Noms courts des articles

+

Affichés sur les cartes et dans le menu circulaire (les titres d'articles sont souvent trop longs). Laisser vide pour utiliser le début du titre.

+ + + + + + + + + +
+ + +
+
+ + + $skill !== '' ? $skill : 'Sans nom', 'items' => $items); + } + update_option('yb_proofs', $clean, false); + foreach ((array) wp_unslash($_POST['yb_short_label'] ?? array()) as $id => $lbl) { + $id = (int) $id; + if (get_post_type($id) !== 'post') continue; + $lbl = sanitize_text_field($lbl); + $lbl === '' ? delete_post_meta($id, '_yb_short_label') : update_post_meta($id, '_yb_short_label', $lbl); + } + wp_safe_redirect(admin_url('admin.php?page=yb-competences&updated=1')); + exit; +}); diff --git a/inc/seasons.php b/inc/seasons.php new file mode 100644 index 0000000..084230a --- /dev/null +++ b/inc/seasons.php @@ -0,0 +1,222 @@ +). + * Auto : voir yb2026_season_auto() (dates en heure de Paris). + * Aperçu sans rien enregistrer : ?saison= | aucune + * Le visiteur peut couper le décor (bouton dans la barre, mémorisé : localStorage yb-season=off). + */ + +function yb2026_seasons() +{ + $ico = array( + 'pumpkin' => '', + 'flake' => '', + 'burst' => '', + 'clover' => '', + 'egg' => '', + 'fish' => '', + 'note' => '', + 'cake' => '', + 'crescent' => '', + 'torii' => '', + 'prompt' => '', + 'tag' => '', + ); + $s = array( + 'nouvel-an' => array('Nouvel An', 'burst', 'du 28 décembre au 6 janvier'), + 'st-patrick' => array('Saint-Patrick', 'clover', 'du 14 au 17 mars'), + 'anniversaire'=> array('Anniversaire', 'cake', 'le 14 mai'), + 'algerie' => array('Indépendance de l\'Algérie', 'crescent', 'du 2 au 8 juillet'), + 'japon' => array('Japon', 'torii', 'du 20 mars au 10 avril (hanami)'), + 'hack' => array('Hack', 'prompt', 'le 30 novembre (journée de la sécurité informatique)'), + 'paques' => array('Pâques', 'egg', 'du jeudi au lundi de Pâques'), + 'poisson' => array('Poisson d\'avril', 'fish', 'le 1er avril'), + 'musique' => array('Fête de la musique', 'note', 'du 19 au 21 juin'), + 'juillet' => array('14 juillet', 'burst', 'du 12 au 14 juillet'), + 'halloween' => array('Halloween', 'pumpkin', 'du 1er octobre au 2 novembre'), + 'black-friday'=> array('Black Friday', 'tag', 'du Black Friday au Cyber Monday'), + 'noel' => array('Noël', 'flake', 'du 1er au 27 décembre'), + ); + $out = array(); + foreach ($s as $k => $v) $out[$k] = array( + 'label' => $v[0], 'when' => $v[2], + 'off' => 'Couper le décor ' . $v[0], 'on' => 'Remettre le décor ' . $v[0], + 'icon' => '', + ); + return $out; +} + +/* Saison selon la date (heure de Paris). $ts : horodatage à tester (défaut : maintenant) */ +function yb2026_season_auto($ts = null) +{ + $ts = $ts ?? time(); + $y = (int) wp_date('Y', $ts); + $md = (int) wp_date('nd', $ts); // ex. 1031 = 31 octobre, 106 = 6 janvier + $day = function ($m, $d, $yy) { return (int) wp_date('Ymd', mktime(12, 0, 0, $m, $d, $yy)); }; + $today = (int) wp_date('Ymd', $ts); + + /* Pâques (calcul grégorien de Meeus) : du jeudi saint au lundi de Pâques */ + $a = $y % 19; $b = intdiv($y, 100); $c = $y % 100; $d = intdiv($b, 4); $e = $b % 4; + $f = intdiv($b + 8, 25); $g = intdiv($b - $f + 1, 3); $h = (19 * $a + $b - $d - $g + 15) % 30; + $i = intdiv($c, 4); $k = $c % 4; $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; $m = intdiv($a + 11 * $h + 22 * $l, 451); + $em = intdiv($h + $l - 7 * $m + 114, 31); $ed = (($h + $l - 7 * $m + 114) % 31) + 1; + /* Black Friday : lendemain du 4e jeudi de novembre, jusqu'au Cyber Monday */ + $thu = 1 + ((4 - (int) date('w', mktime(12, 0, 0, 11, 1, $y)) + 7) % 7) + 21; + + if ($md === 401) return 'poisson'; + if ($today >= $day($em, $ed - 3, $y) && $today <= $day($em, $ed + 1, $y)) return 'paques'; + if ($md === 1130) return 'hack'; // Computer Security Day, passe devant le Cyber Monday + if ($today >= $day(11, $thu + 1, $y) && $today <= $day(11, $thu + 4, $y)) return 'black-friday'; + if ($md >= 1228 || $md <= 106) return 'nouvel-an'; + if ($md >= 314 && $md <= 317) return 'st-patrick'; + if ($md === 514) return 'anniversaire'; + if ($md >= 702 && $md <= 708) return 'algerie'; // semaine autour du 5 juillet + if ($md >= 320 && $md <= 410) return 'japon'; // hanami (après poisson d'avril et Pâques, qui passent devant) + if ($md >= 619 && $md <= 621) return 'musique'; + if ($md >= 712 && $md <= 714) return 'juillet'; + if ($md >= 1001 && $md <= 1102) return 'halloween'; + if ($md >= 1201 && $md <= 1227) return 'noel'; + return ''; +} + +/* Saison active pour cette requête ('' = aucune) */ +/* Pages sobres, jamais décorées (comme les pages d'erreur Apache / NPM, qui ne connaissent pas la saison) */ +function yb2026_season_blocked() { return is_404(); } + +function yb2026_season() +{ + static $s = null; + if ($s !== null) return $s; + if (yb2026_season_blocked()) return $s = ''; + $all = yb2026_seasons(); + if (isset($_GET['saison'])) { + $q = sanitize_key($_GET['saison']); + return $s = isset($all[$q]) ? $q : ''; + } + $mode = (string) get_option('yb_season', 'auto'); + if ($mode === 'off') return $s = ''; + return $s = isset($all[$mode]) ? $mode : yb2026_season_auto(); +} + +/* Quantité d'éléments animés par fête, en % (100 = normal). Option yb_season_density */ +function yb2026_season_density() +{ + $d = (array) get_option('yb_season_density', array()); + $out = array(); + foreach (array_keys(yb2026_seasons()) as $k) if (isset($d[$k])) $out[$k] = max(0, min(300, (int) $d[$k])); + return $out; +} + +/* Fêtes qui ont un CV assorti (wp-content/uploads/cv/saisons//) */ +function yb2026_season_cvs() +{ + $out = array(); + foreach (array_keys(yb2026_seasons()) as $k) if (file_exists(WP_CONTENT_DIR . '/uploads/cv/saisons/' . $k . '/CV_Yasser_Boulayoune.pdf')) $out[] = $k; + return $out; +} + +/* Attribut de : fête active */ +function yb2026_season_attrs() +{ + if (yb2026_season_blocked()) return ' data-no-season'; + $a = ''; + if (yb2026_season()) $a .= ' data-season="' . esc_attr(yb2026_season()) . '"'; + return $a; +} + +add_action('wp_enqueue_scripts', function () { + if (yb2026_season_blocked()) return; + $dir = get_template_directory(); + $uri = get_template_directory_uri(); + wp_enqueue_style('yb-seasons', $uri . '/assets/css/seasons.css', array('yb-style'), filemtime($dir . '/assets/css/seasons.css')); + wp_enqueue_script('yb-seasons', $uri . '/assets/js/seasons.js', array('yb-effects'), filemtime($dir . '/assets/js/seasons.js'), true); + $list = array(); + /* accent (mode sombre) de chaque fête : sert à teinter la main du curseur dans la roue */ + $acc = array('nouvel-an' => '#f2c14e', 'st-patrick' => '#3ddc84', 'anniversaire' => '#2ee6c5', 'paques' => '#ff9fcb', 'poisson' => '#4fd1ff', + 'musique' => '#ff5cc8', 'juillet' => '#ff4a5a', 'halloween' => '#ff7b1c', 'black-friday' => '#ffd400', 'noel' => '#ff4a5a', + 'algerie' => '#2fbf71', 'japon' => '#ff5a3c', 'hack' => '#00ff66'); + foreach (yb2026_seasons() as $k => $t) $list[$k] = array('label' => $t['label'], 'when' => $t['when'], 'icon' => $t['icon'], 'accent' => $acc[$k] ?? ''); + wp_add_inline_script('yb-seasons', 'window.YB_SEASONS=' . wp_json_encode(array('list' => $list, 'auto' => yb2026_season_auto(), 'cv' => yb2026_season_cvs(), 'density' => (object) yb2026_season_density())) . ';', 'before'); +}, 20); + +/* Bouton de la barre (icône de la fête) : coupe / remet le décor */ +function yb2026_season_toggle() +{ + if (yb2026_season_blocked()) return; + $s = yb2026_season(); + $t = $s ? yb2026_seasons()[$s] : array('label' => 'de fête', 'off' => 'Thème de fête', 'on' => 'Thème de fête', 'icon' => yb2026_seasons()['nouvel-an']['icon']); + printf( + '', + esc_attr($t['off']), esc_attr($t['on']), esc_attr('Décor ' . $t['label']), $t['icon'] + ); +} + +/* ── Admin : Profil → Saisons ───────────────────────────────────────── */ + +add_action('admin_menu', function () { + add_submenu_page('yb-profil', 'Saisons', 'Saisons', 'edit_pages', 'yb-saisons', 'yb2026_seasons_page'); +}); + +function yb2026_seasons_page() +{ + $mode = (string) get_option('yb_season', 'auto'); + $auto = yb2026_season_auto(); + $all = yb2026_seasons(); + $opts = array('auto' => 'Automatique' . ($auto ? ' (en ce moment : ' . $all[$auto]['label'] . ')' : ' (en ce moment : aucune fête)')); + foreach ($all as $k => $t) $opts[$k] = 'Toujours ' . $t['label'] . ' (en auto : ' . $t['when'] . ')'; + $opts['off'] = 'Désactivé'; + ?> +
+

Thèmes saisonniers

+

Réglage enregistré.

+

Palette, décor animé et orbe de l'accueil changent selon la saison. Les visiteurs peuvent couper le décor avec le bouton de la barre de navigation.

+
+ + +
+ $l) : ?> +

+ +
+

Quantité d'éléments animés

+

Combien d'objets apparaissent en fond (étiquettes, ballons, flocons, chauves-souris, feux d'artifice...). 100 % = réglage d'origine, 0 % = aucun.

+ 'feux d\'artifice', 'st-patrick' => 'trèfles', 'anniversaire' => 'ballons et confettis', 'paques' => 'pétales', + 'poisson' => 'poissons et bulles', 'musique' => 'notes', 'juillet' => 'feux d\'artifice', 'halloween' => 'chauves-souris et braises', + 'black-friday' => 'étiquettes de prix', 'noel' => 'flocons', 'algerie' => 'feux d\'artifice et confettis', 'japon' => 'pétales de sakura', 'hack' => 'pluie de caractères'); ?> + + $t) : $v = $dens[$k] ?? 100; ?> + + + + + + +

Aperçu sans changer le réglage : + $t) : ?> · + Sans décor +

+ +
+
+ $v) { + $k = sanitize_key($k); + if (isset(yb2026_seasons()[$k])) $dens[$k] = max(0, min(300, (int) $v)); + } + update_option('yb_season_density', $dens, false); + update_option('yb_season', ($v === 'auto' || $v === 'off' || isset(yb2026_seasons()[$v])) ? $v : 'auto'); + wp_safe_redirect(admin_url('admin.php?page=yb-saisons&updated=1')); + exit; +}); diff --git a/page-competences.php b/page-competences.php new file mode 100644 index 0000000..8e2b1a9 --- /dev/null +++ b/page-competences.php @@ -0,0 +1,173 @@ +publish; +$nb_posts = (int) wp_count_posts('post')->publish; + +/* Boîte à outils : uniquement ce que prouvent les réalisations, articles et le poste */ +$toolbox = array( + array('t' => 'Développement web', 'i' => array('PHP 8', 'Symfony', 'Twig Components', 'Doctrine', 'WordPress sur mesure', 'PHP/MVC sans framework', 'JavaScript vanilla', 'HTML / CSS responsive')), + array('t' => 'Données & outillage', 'i' => array('MariaDB / MySQL', 'PostgreSQL', 'SQL', 'API HTTP / JSON', 'Python 3', 'Node.js', 'JS ESM', 'FFmpeg', 'librsvg')), + array('t' => 'Systèmes & conteneurs', 'i' => array('Debian 12', 'Docker / Compose', 'systemd', 'Apache', 'Nginx Proxy Manager', 'TLS Let\'s Encrypt', 'Réglage mémoire JVM')), + array('t' => 'Réseau & sécurité', 'i' => array('VLAN', 'DNS', 'DHCP', 'WireGuard', 'Pi-hole', 'Active Directory', 'Reverse proxy', 'Services privés par défaut')), + array('t' => 'Support & MCO', 'i' => array('Diagnostic d\'incidents', 'Lecture de logs', 'Accompagnement utilisateurs', 'Procédures documentées', 'Supervision Uptime Kuma', 'Sauvegardes planifiées')), + array('t' => 'Outils du quotidien', 'i' => array('Git', 'Forgejo / Gitea', 'GitHub', 'VS Code / code-server', 'Terminal & SSH')), +); + +/* Méthode : tirée de mes retours d'expérience (OOM killer, faux bug, servyass) */ +$method = array( + array('t' => 'Observer', 'd' => 'Lire les journaux en entier, reproduire le symptôme, noter ce qui change et ce qui ne change pas.'), + array('t' => 'Isoler', 'd' => 'Réduire le périmètre : environnement, ressources, configuration, code. Vérifier aussi l\'outil de mesure.'), + array('t' => 'Corriger', 'd' => 'Formuler une hypothèse, la tester, appliquer le correctif le plus simple qui traite la vraie cause.'), + array('t' => 'Documenter', 'd' => 'Écrire la procédure, pour que la même panne se règle en cinq minutes la prochaine fois.'), +); + +/* Preuves : gérées dans l'admin (Profil → Compétences), voir inc/proofs.php */ +$proofs = yb2026_get_proofs(); + +$containers = array('npm', 'jellyfin', 'forgejo', 'code-server', 'wordpress + mariadb', 'passbolt + mariadb', 'poste.io', 'uptime-kuma', 'filebrowser · alist', 'wireguard'); +$infra_points = array( + array('t' => 'Pas un lab jetable', 'd' => 'De la prod perso avec de vrais utilisateurs, de vraies pannes et de vrais correctifs. Si Jellyfin tombe, on me le fait savoir.'), + array('t' => 'Incidents gérés', 'd' => 'OOM, conflits de ports, migration complète de matériel, watchdog réseau, certificats TLS : diagnostiqués et résolus seul.'), + array('t' => 'Méthode', 'd' => 'Supervision Uptime Kuma, sauvegardes planifiées, mises à jour de sécurité régulières, procédures documentées.'), +); +?> + + +
+ + +
+
CE QUE JE SAIS FAIRE
+

COMPÉTENCES

+

+ J'ai livré projets, + écrit articles techniques, + et je fais tourner 37 services en production + depuis 2 ans. +

+
+ + +
+ $sv) : ?> +
+ +

+

+
+ +
+ + +
+
+
BOÎTE À OUTILS
+

CE QUE J'UTILISE

+
+ $tb) : ?> +
+
+
+ +
+
+ +
+ + +
+
+
MA MÉTHODE
+

QUAND ÇA CASSE

+ +
+
    + $m) : ?> +
  1. + / + + +
  2. + +
+
+ + +
+
$ ssh yass@servyass
+

L'INFRA SERVYASS · EN PROD DEPUIS 2 ANS

+

Je ne me contente pas de lister des compétences : 2 ans d'infrastructure Linux/Docker auto-hébergée, administrée seul, en production réelle. Debian 12 · Docker Engine · Ryzen 3 3200G · 13 GB RAM.

+
+
+
+ + yass@servyass: docker ps +
+
+
$ docker ps --format table · NAME · STATUS
+ +
Up (healthy)
+ +
$ uptime · ▲ en production depuis 2 ans, maintenu en autonomie
+
+
+
+ +
+
+
+
+ +
+
+
FLUX EN DIRECT · 16+ CONTENEURS · 1 SEUL ADMIN · SUPERVISION UPTIME-KUMA · BACKUPS PLANIFIÉS · MÀJ SÉCU HEBDO
+
+ + + +
+
+
PREUVES À L'APPUI · PROJETS & ARTICLES
+

CHAQUE COMPÉTENCE A SON PROJET

+
+
    + $links) : + $first = $links[0]; ?> +
  • + + + + ARTICLE → + + +
  • + +
+ + +
+ + + +
+
+ TRAVAILLONS
ENSEMBLE →
+
+
+ + diff --git a/page-realisation.php b/page-realisation.php new file mode 100644 index 0000000..6093ad8 --- /dev/null +++ b/page-realisation.php @@ -0,0 +1,17 @@ + + +
+
PROJETS · FORMATION & CONTEXTE PRO
+

MES
RÉALISATIONS

+

Du développement web à l'administration d'infrastructures. Cliquer sur un projet ouvre l'étude de cas.

+ +
+ +
+
+ + diff --git a/single.php b/single.php new file mode 100644 index 0000000..52ab711 --- /dev/null +++ b/single.php @@ -0,0 +1,50 @@ + + +
+ +
+ 'case-hero-img')); ?> +
+
+
JOURNAL TECHNIQUE ·
+

+
+
+ +
+
JOURNAL TECHNIQUE ·
+

+
+ + +
+ +
+
+
+
+ + + +
+
ARTICLE SUIVANT
+
+
+
→
+
+ +
+ +