Pages Compétences, Contact et Qui suis-je

This commit is contained in:
Yasser Boulayoune 2026-10-08 13:19:00 +02:00
parent e06ad42369
commit da05adede1
10 changed files with 1242 additions and 0 deletions

15
README.md Normal file
View file

@ -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.
```

109
front-page.php Normal file
View file

@ -0,0 +1,109 @@
<?php
get_header();
$projects = yb2026_get_projects();
$home_projects = array_slice($projects, 0, 6);
$years = array_filter(array_map(function ($p) { return get_post_meta($p->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'));
?>
<!-- Orbe persistante : canvas fixe derrière toute la page, morphée par le scroll (orb.js) -->
<canvas id="orb-canvas" class="orb-canvas" aria-hidden="true"></canvas>
<!-- Rail de navigation vertical -->
<nav class="section-rail" aria-label="Sections de la page">
<a href="#top" data-rail-target="#top">TOP</a>
<a href="#travaux" data-rail-target="#travaux">TRAVAUX</a>
<a href="#apropos" data-rail-target="#apropos">PROFIL</a>
<a href="#cta" data-rail-target="#cta">CONTACT</a>
</nav>
<!-- HERO -->
<section class="hero" id="top">
<div class="hero__vertical-label">PORTFOLIO · ÉDITION <?php echo esc_html(date('Y')); ?></div>
<div class="hero__content">
<div class="line-mask hero__kicker-mask"><div class="hero__kicker"><?php echo esc_html($kicker); ?></div></div>
<h1 class="hero__title">
<span class="line-mask"><span>YASSER</span></span>
<span class="line-mask"><span class="outline-lime">BOULAYOUNE</span></span>
</h1>
<div class="line-mask hero__sub-mask"><div class="hero__sub"><?php echo esc_html($sub); ?></div></div>
</div>
<div class="hero__aside">
<p><?php echo esc_html(yb2026_id('hero_texte')); ?></p>
<a class="scroll-hint" href="#travaux">SCROLL TO EXPLORE<span class="scroll-hint__arrow">→</span></a>
</div>
<div class="hero__marquee marquee" aria-hidden="true">
<div class="marquee-track">
<?php for ($k = 0; $k < 2; $k++) : ?>
<span>PHP <span class="lime">✦</span> WORDPRESS <span class="lime">✦</span> SYMFONY <span class="lime">✦</span> JAVASCRIPT <span class="lime">✦</span> PYTHON <span class="lime">✦</span> DOCKER <span class="lime">✦</span> LINUX <span class="lime">✦</span>&nbsp;</span>
<?php endfor; ?>
</div>
</div>
</section>
<!-- TRAVAUX : scroll horizontal + skew (desktop), scroll-snap natif (mobile) -->
<section class="works" id="travaux">
<div class="works-sticky">
<div class="works-head">
<div data-reveal>
<h2 class="works-title">TRAVAUX<span class="lime">.</span></h2>
<div class="works-sub">SELECTED WORKS · <?php echo esc_html($span); ?></div>
</div>
<div class="works-meta">
<div class="works-hint">( SCROLL · LA SECTION GLISSE → )</div>
<div class="works-count">01 / <?php echo esc_html(str_pad(count($home_projects), 2, '0', STR_PAD_LEFT)); ?></div>
</div>
</div>
<div class="works-track">
<?php foreach ($home_projects as $p) yb2026_project_card($p, true); ?>
<a class="works-end" href="<?php echo esc_url(home_url('/realisation/')); ?>" data-wipe="RÉALISATIONS">
<span>TOUT VOIR<br>( <?php echo esc_html(str_pad(count($projects), 2, '0', STR_PAD_LEFT)); ?> ) →</span>
</a>
</div>
</div>
</section>
<!-- À PROPOS : blob + blend-mode -->
<section class="about" id="apropos">
<div class="about-blob" aria-hidden="true"></div>
<div class="about__title-wrap">
<h2 class="about__title" data-reveal>CODE.<br>SERVEURS.<br>RÉSEAUX.</h2>
</div>
<div class="about__cols">
<p class="about__text" data-reveal><?php echo yb2026_lime_marks(yb2026_id('apropos')); ?></p>
<div class="about__list" data-reveal>
<div><span class="lime">▸</span> WEB · PHP, WordPress, Symfony, JS</div>
<div><span class="lime">▸</span> SYS · Debian, Docker, virtualisation</div>
<div><span class="lime">▸</span> RÉSEAU · VLAN, DNS, DHCP, WireGuard</div>
<div><span class="lime">▸</span> OPS · supervision, backups, TLS</div>
</div>
</div>
<div class="about__more">
<a class="link-underline" href="<?php echo esc_url(home_url('/qui-suis-je/')); ?>" data-wipe="QUI SUIS-JE">EN SAVOIR PLUS SUR MOI →</a>
</div>
</section>
<!-- MARQUEES compétences -->
<section class="skills-mq" aria-hidden="true">
<div class="marquee">
<div class="marquee-track skills-mq__track">
<?php for ($k = 0; $k < 2; $k++) : ?><span>PHP / WORDPRESS / SYMFONY / JAVASCRIPT / PYTHON / NODE / DOCKER / GIT /&nbsp;</span><?php endfor; ?>
</div>
</div>
<div class="marquee">
<div class="marquee-track marquee-track--reverse skills-mq__track skills-mq__track--outline">
<?php for ($k = 0; $k < 2; $k++) : ?><span>DEBIAN / NGINX / WIREGUARD / VLAN / DNS / ACTIVE DIRECTORY / SQL / SUPERVISION /&nbsp;</span><?php endfor; ?>
</div>
</div>
</section>
<!-- CTA contact -->
<section class="cta" id="cta">
<div class="kicker" data-reveal><?php echo esc_html(mb_strtoupper(yb2026_id('accroche'))); ?></div>
<a class="cta__title" href="<?php echo esc_url(home_url('/contact/')); ?>" data-wipe="CONTACT" data-cursor-label="M'ÉCRIRE" data-reveal>TRAVAILLONS<br><span class="outline-lime">ENSEMBLE →</span></a>
</section>
<?php get_footer(); ?>

72
inc/error-template.php Normal file
View file

@ -0,0 +1,72 @@
<?php
/* Généré par ~/yb-erreurs/build.py : ne pas modifier à la main. Variables : $yb_code, $yb_title, $yb_msg (HTML sûr). */
?><!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#0a0a0a">
<title><?php echo esc_html($yb_code . ' · ' . $yb_title); ?> · Yasser Boulayoune</title>
<script>try{var t=localStorage.getItem('yb-theme');if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
<style>@font-face{font-family:'Archivo';font-weight:100 900;font-stretch:62% 125%;font-display:swap;src:url(/wp-content/themes/yb2026/assets/fonts/Archivo-100-900-latin.woff2) format('woff2')}
@font-face{font-family:'JetBrains Mono';font-weight:400 700;font-display:swap;src:url(/wp-content/themes/yb2026/assets/fonts/JetBrainsMono-400-latin.woff2) format('woff2')}
:root{--bg:#0a0a0a;--ink:#f5f5f0;--lime:#d3fd50;--ink-rgb:245,245,240;--lime-rgb:211,253,80;--mut:rgba(245,245,240,.55);--b:rgba(245,245,240,.12);color-scheme:dark}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--bg:#f3f2ed;--ink:#111;--lime:#4a7300;--ink-rgb:17,17,17;--lime-rgb:74,115,0;--mut:rgba(17,17,17,.55);--b:rgba(17,17,17,.12);color-scheme:light}}
:root[data-theme="light"]{--bg:#f3f2ed;--ink:#111;--lime:#4a7300;--ink-rgb:17,17,17;--lime-rgb:74,115,0;--mut:rgba(17,17,17,.55);--b:rgba(17,17,17,.12);color-scheme:light}
*{box-sizing:border-box;margin:0;padding:0}
html,body{min-height:100%;background:var(--bg);overflow-x:clip}
body{color:var(--ink);font:400 16px/1.6 'Archivo',system-ui,sans-serif;display:flex;flex-direction:column;min-height:100vh;padding:0 clamp(20px,4vw,44px);overflow-x:hidden}
::selection{background:var(--lime);color:var(--bg)}
a{color:inherit;text-decoration:none}
.grain{position:fixed;inset:0;pointer-events:none;opacity:.05;z-index:5;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)'/%3E%3C/svg%3E")}
.top{display:flex;justify-content:space-between;align-items:center;padding:22px 0;border-bottom:1px solid var(--b);font:700 14px 'JetBrains Mono',monospace;letter-spacing:.05em}
.top b{color:var(--lime);font-weight:700}
.top span{font-weight:500;font-size:11.5px;letter-spacing:.18em;color:var(--mut)}
main{flex:1;display:flex;flex-direction:column;justify-content:center;gap:22px;padding:48px 0;position:relative;min-width:0}
.ring{position:absolute;right:-6vw;top:50%;width:min(56vw,620px);aspect-ratio:1;transform:translateY(-50%);border-radius:50%;pointer-events:none;
background:radial-gradient(circle,rgba(var(--lime-rgb),.10),transparent 62%);border:1px dashed rgba(var(--lime-rgb),.22);animation:spin 60s linear infinite}
.ring::after{content:'';position:absolute;inset:16%;border-radius:50%;border:1px solid rgba(var(--ink-rgb),.08)}
@keyframes spin{to{transform:translateY(-50%) rotate(360deg)}}
.kick{font:700 12px 'JetBrains Mono',monospace;letter-spacing:.22em;color:var(--lime)}
.code{font:900 clamp(110px,26vw,300px)/.82 'Archivo',sans-serif;font-stretch:118%;letter-spacing:-.05em;display:flex;gap:.04em;user-select:none}
.code span:nth-child(odd){color:var(--ink)}
.code span:nth-child(even){color:transparent;-webkit-text-stroke:2px var(--lime)}
h1{font:800 clamp(28px,4.4vw,52px)/1.05 'Archivo',sans-serif;letter-spacing:-.025em;text-wrap:balance;max-width:16ch}
p.msg{max-width:52ch;color:rgba(var(--ink-rgb),.78);font-size:17px}
.req{font:500 12.5px 'JetBrains Mono',monospace;color:var(--mut);display:flex;gap:10px;flex-wrap:wrap;align-items:center;max-width:100%}
.req code{font:inherit;color:var(--ink);background:rgba(var(--ink-rgb),.06);border:1px solid var(--b);padding:3px 8px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.req em{font-style:normal;color:var(--lime)}
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:6px}
.btn{font:700 12.5px 'JetBrains Mono',monospace;letter-spacing:.14em;padding:14px 20px;border-radius:99px;border:1px solid var(--b);transition:transform .25s cubic-bezier(.3,1.5,.5,1),border-color .2s,color .2s,background .2s;cursor:pointer;background:none;color:var(--ink)}
.btn:hover{border-color:var(--lime);color:var(--lime);transform:translateY(-2px)}
.btn.main{background:var(--lime);border-color:var(--lime);color:var(--bg)}
.btn.main:hover{color:var(--bg)}
.btn:focus-visible{outline:2px solid var(--lime);outline-offset:3px}
footer{padding:20px 0 26px;border-top:1px solid var(--b);font:500 11px 'JetBrains Mono',monospace;letter-spacing:.16em;color:var(--mut);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
@media (max-width:640px){.ring{right:-30vw;width:90vw}.top span{display:none}}
</style>
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<header class="top"><a href="/">YB<b>©</b>2026</a><span>YASSER BOULAYOUNE · PORTFOLIO</span></header>
<main>
<div class="ring" aria-hidden="true"></div>
<p class="kick">ERREUR <?php echo esc_html($yb_code); ?></p>
<div class="code" aria-hidden="true"><?php foreach (str_split((string) $yb_code) as $d) echo '<span>' . esc_html($d) . '</span>'; ?></div>
<h1><?php echo esc_html($yb_title); ?></h1>
<p class="msg"><?php echo $yb_msg; ?></p>
<p class="req"><span>REQUÊTE</span><code id="yb-path">/</code><em>→ <?php echo esc_html($yb_code); ?></em></p>
<div class="actions">
<a class="btn main" href="/">ACCUEIL →</a>
<button class="btn" type="button" id="yb-back">← RETOUR</button>
<a class="btn" href="/contact/">CONTACT</a>
</div>
</main>
<footer><span>© 2026 YASSER BOULAYOUNE</span><span>RÉGION LYONNAISE</span></footer>
<script>
(function(){var p=document.getElementById('yb-path');if(p)p.textContent=location.pathname+location.search;
var b=document.getElementById('yb-back');if(b){if(history.length<2)b.hidden=true;b.addEventListener('click',function(){history.back()});}})();
</script>
</body>
</html>

51
inc/errors.php Normal file
View file

@ -0,0 +1,51 @@
<?php
/**
* Pages d'erreur WordPress au style du portfolio.
* - wp_die() côté site public (l'admin, la page de connexion, AJAX/REST gardent l'affichage natif)
* - wp-content/php-error.php (erreur fatale PHP) utilise le même modèle : inc/error-template.php
* Le modèle est généré par ~/yb-erreurs/build.py (pages Apache et NPM identiques).
*/
function yb2026_error_titles()
{
return array(400 => '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('<br>', 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('#</?p[^>]*>#i', ' ', (string) $message)); // le modèle fournit déjà le paragraphe
yb2026_render_error($code, $t, wp_kses_post($message));
};
});

323
inc/profile.php Normal file
View file

@ -0,0 +1,323 @@
<?php
/**
* Page d'admin « Profil » : poste, coordonnées, textes clés, parcours,
* compétences et services, modifiables sans toucher au code.
*
* ACF Pro n'est plus actif (licence perdue, voir plugin acf-pro-shim en lecture
* seule) : les répéteurs sont donc faits maison, stockés dans 4 options WP :
* yb_identity (tableau clé => 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', '<span class="lime">$1</span>', 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');
?>
<div class="wrap yb-profil">
<h1>Profil</h1>
<p>Ces informations alimentent tout le site : hero, nav, Qui suis-je, Compétences, Contact, footer et données SEO.</p>
<?php if (isset($_GET['updated'])) : ?><div class="notice notice-success is-dismissible"><p>Profil enregistré.</p></div><?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="yb_save_profile">
<?php wp_nonce_field('yb_save_profile'); ?>
<h2>Situation et coordonnées</h2>
<table class="form-table" role="presentation">
<?php foreach (yb2026_identity_fields() as $key => $f) : ?>
<tr>
<th scope="row"><label for="yb-id-<?php echo esc_attr($key); ?>"><?php echo esc_html($f[0]); ?></label></th>
<td>
<?php if ($f[1] === 'textarea') : ?>
<textarea class="large-text" rows="3" id="yb-id-<?php echo esc_attr($key); ?>" name="yb_identity[<?php echo esc_attr($key); ?>]"><?php echo esc_textarea($identity[$key]); ?></textarea>
<?php else : ?>
<input class="regular-text" type="text" id="yb-id-<?php echo esc_attr($key); ?>" name="yb_identity[<?php echo esc_attr($key); ?>]" value="<?php echo esc_attr($identity[$key]); ?>">
<?php endif; ?>
<?php if ($f[2]) : ?><p class="description"><?php echo esc_html($f[2]); ?></p><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php foreach (yb2026_profile_repeaters() as $opt => $rep) :
$rows = yb2026_profile($opt); ?>
<h2><?php echo esc_html($rep['title']); ?></h2>
<p class="description"><?php echo esc_html($rep['help']); ?></p>
<table class="widefat striped yb-rep" data-opt="<?php echo esc_attr($opt); ?>">
<thead><tr>
<?php foreach ($rep['fields'] as $f) : ?><th><?php echo esc_html($f[0]); ?></th><?php endforeach; ?>
<th class="yb-rep__actions"></th>
</tr></thead>
<tbody>
<?php foreach ($rows as $i => $row) yb2026_profile_row($opt, $rep['fields'], $i, $row); ?>
</tbody>
</table>
<template class="yb-rep__tpl" data-opt="<?php echo esc_attr($opt); ?>"><?php yb2026_profile_row($opt, $rep['fields'], '__i__', array()); ?></template>
<p><button type="button" class="button yb-rep__add" data-opt="<?php echo esc_attr($opt); ?>">+ Ajouter une ligne</button></p>
<?php endforeach; ?>
<?php submit_button('Enregistrer le profil'); ?>
</form>
</div>
<style>
.yb-profil .yb-rep td { vertical-align: top; }
.yb-profil .yb-rep input[type=text], .yb-profil .yb-rep textarea { width: 100%; }
.yb-profil .yb-rep input[type=number] { width: 5em; }
.yb-profil .yb-rep__actions { width: 7.5em; white-space: nowrap; }
.yb-profil .yb-rep__actions .button { min-width: 2em; padding: 0 .4em; }
</style>
<script>
(function () {
var n = Date.now();
document.querySelectorAll('.yb-rep__add').forEach(function (btn) {
btn.addEventListener('click', function () {
var opt = btn.dataset.opt;
var tpl = document.querySelector('.yb-rep__tpl[data-opt="' + opt + '"]');
var body = document.querySelector('.yb-rep[data-opt="' + opt + '"] tbody');
body.insertAdjacentHTML('beforeend', tpl.innerHTML.replace(/__i__/g, 'n' + (n++)));
});
});
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-row-act]');
if (!b) return;
var tr = b.closest('tr');
var act = b.dataset.rowAct;
if (act === 'del') tr.remove();
if (act === 'up' && tr.previousElementSibling) tr.parentNode.insertBefore(tr, tr.previousElementSibling);
if (act === 'down' && tr.nextElementSibling) tr.parentNode.insertBefore(tr.nextElementSibling, tr);
});
})();
</script>
<?php
}
/** Une ligne de répéteur (aussi utilisée comme modèle <template>). */
function yb2026_profile_row($opt, $fields, $i, $row)
{
echo '<tr>';
foreach ($fields as $key => $f) {
$name = $opt . '[' . $i . '][' . $key . ']';
$val = isset($row[$key]) ? $row[$key] : '';
echo '<td>';
if ($f[1] === 'textarea') {
echo '<textarea rows="2" name="' . esc_attr($name) . '" aria-label="' . esc_attr($f[0]) . '">' . esc_textarea($val) . '</textarea>';
} else {
$extra = $f[1] === 'number' ? ' min="0" max="100"' : '';
echo '<input type="' . esc_attr($f[1]) . '"' . $extra . ' name="' . esc_attr($name) . '" value="' . esc_attr($val) . '" aria-label="' . esc_attr($f[0]) . '">';
}
echo '</td>';
}
echo '<td class="yb-rep__actions">'
. '<button type="button" class="button" data-row-act="up" aria-label="Monter">↑</button> '
. '<button type="button" class="button" data-row-act="down" aria-label="Descendre">↓</button> '
. '<button type="button" class="button" data-row-act="del" aria-label="Supprimer">✕</button>'
. '</td></tr>';
}
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 '<script type="application/ld+json">' . wp_json_encode($person, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>' . "\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 '<span class="yb-pv" data-pv="' . esc_attr(yb2026_obf($value)) . '">' . str_repeat('•', 8) . '</span>';
}
/** 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) {
/* <a href="mailto:x">x</a> → lien protégé */
$html = preg_replace_callback('#<a\b([^>]*?)href=(["\'])mailto:' . preg_quote($email, '#') . '\2([^>]*)>(.*?)</a>#is', function ($m) use ($email) {
$inner = str_ireplace($email, yb2026_pv($email), $m[4]);
return '<a' . $m[1] . yb2026_pv_href('mailto:' . $email) . $m[3] . '>' . $inner . '</a>';
}, $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);

210
inc/proofs.php Normal file
View file

@ -0,0 +1,210 @@
<?php
/**
* Compétences de la page /competences/ (bloc « Chaque compétence a son projet »).
*
* Stockage : option `yb_proofs` = liste de [ 'skill' => 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 = '<option value="">+ Ajouter une réalisation ou un article…</option><optgroup label="Réalisations">';
foreach ($reals as $p) $h .= '<option value="' . (int) $p->ID . '">' . esc_html($label($p)) . '</option>';
$h .= '</optgroup><optgroup label="Articles du blog">';
foreach ($arts as $p) $h .= '<option value="' . (int) $p->ID . '">' . esc_html($label($p)) . '</option>';
return $h . '</optgroup>';
};
$chip = function ($id, $row) use ($all) {
return '<span class="ybp-chip" draggable="true"><input type="hidden" name="yb_proofs[' . esc_attr($row) . '][items][]" value="' . (int) $id . '">'
. esc_html($all[$id] ?? ('#' . $id)) . ' <button type="button" class="ybp-chip__x" aria-label="Retirer">✕</button></span>';
};
?>
<div class="wrap ybp">
<h1>Compétences</h1>
<p>Chaque compétence apparaît sur la page <a href="<?php echo esc_url(home_url('/competences/')); ?>" target="_blank">Compétences</a>, dans « Chaque compétence a son projet ». Le <strong>premier</strong> contenu d'une compétence est la carte de devant ; avec plusieurs contenus, un clic ouvre le menu circulaire.</p>
<?php if (isset($_GET['updated'])) : ?><div class="notice notice-success is-dismissible"><p>Compétences enregistrées.</p></div><?php endif; ?>
<?php if ($orphans) : ?>
<div class="notice notice-warning"><p><strong>Pas encore rattachés :</strong> <?php echo esc_html(implode(', ', $orphans)); ?></p></div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="yb_save_proofs">
<?php wp_nonce_field('yb_save_proofs'); ?>
<div id="ybp-rows">
<?php foreach ($rows as $i => $r) : ?>
<div class="ybp-row">
<div class="ybp-row__head">
<input type="text" class="ybp-skill" name="yb_proofs[<?php echo (int) $i; ?>][skill]" value="<?php echo esc_attr($r['skill'] ?? ''); ?>" placeholder="Nom de la compétence">
<button type="button" class="button" data-act="up" aria-label="Monter">↑</button>
<button type="button" class="button" data-act="down" aria-label="Descendre">↓</button>
<button type="button" class="button button-link-delete" data-act="del">Supprimer</button>
</div>
<div class="ybp-chips" data-row="<?php echo (int) $i; ?>"><?php foreach ((array) ($r['items'] ?? array()) as $id) echo $chip($id, $i); ?></div>
<select class="ybp-add"><?php echo $options(); ?></select>
</div>
<?php endforeach; ?>
</div>
<template id="ybp-tpl">
<div class="ybp-row">
<div class="ybp-row__head">
<input type="text" class="ybp-skill" name="yb_proofs[__i__][skill]" value="" placeholder="Nom de la compétence">
<button type="button" class="button" data-act="up" aria-label="Monter">↑</button>
<button type="button" class="button" data-act="down" aria-label="Descendre">↓</button>
<button type="button" class="button button-link-delete" data-act="del">Supprimer</button>
</div>
<div class="ybp-chips" data-row="__i__"></div>
<select class="ybp-add"><?php echo $options(); ?></select>
</div>
</template>
<p><button type="button" class="button" id="ybp-new">+ Nouvelle compétence</button></p>
<h2>Noms courts des articles</h2>
<p class="description">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.</p>
<table class="widefat striped ybp-labels">
<tbody>
<?php foreach ($arts as $p) : ?>
<tr>
<td><?php echo esc_html(get_the_title($p)); ?></td>
<td><input type="text" name="yb_short_label[<?php echo (int) $p->ID; ?>]" value="<?php echo esc_attr((string) get_post_meta($p->ID, '_yb_short_label', true)); ?>" placeholder="<?php echo esc_attr(yb2026_short_title($p)); ?>"></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php submit_button('Enregistrer les compétences'); ?>
</form>
</div>
<style>
.ybp-row { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px; padding: 14px 16px; margin: 0 0 12px; max-width: 980px; }
.ybp-row__head { display: flex; gap: 6px; align-items: center; margin-bottom: 10px; }
.ybp-skill { flex: 1; font-size: 15px; font-weight: 600; }
.ybp-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; margin-bottom: 8px; }
.ybp-chips:empty::before { content: 'Aucun contenu : ajoute une réalisation ou un article ci-dessous.'; color: #8c8f94; font-style: italic; line-height: 30px; }
.ybp-chip { display: inline-flex; align-items: center; gap: 6px; background: #f0f6fc; border: 1px solid #72aee6; border-radius: 99px; padding: 3px 6px 3px 12px; cursor: grab; }
.ybp-chip:first-child { background: #edfaef; border-color: #00a32a; font-weight: 600; }
.ybp-chip:first-child::before { content: 'devant'; font-size: 10px; text-transform: uppercase; color: #00a32a; }
.ybp-chip.is-drag { opacity: .4; }
.ybp-chip__x { border: 0; background: none; cursor: pointer; color: #b32d2e; padding: 0 4px; }
.ybp-add { max-width: 420px; }
.ybp-labels { max-width: 980px; }
.ybp-labels td:last-child input { width: 100%; }
</style>
<script>
(function () {
const rowsEl = document.getElementById('ybp-rows');
let n = Date.now();
const chipHTML = (id, row, text) => '<span class="ybp-chip" draggable="true"><input type="hidden" name="yb_proofs[' + row + '][items][]" value="' + id + '">'
+ text.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])) + ' <button type="button" class="ybp-chip__x" aria-label="Retirer">✕</button></span>';
document.getElementById('ybp-new').addEventListener('click', () => {
rowsEl.insertAdjacentHTML('beforeend', document.getElementById('ybp-tpl').innerHTML.replace(/__i__/g, 'n' + (n++)));
rowsEl.lastElementChild.querySelector('.ybp-skill').focus();
});
document.addEventListener('change', e => {
const sel = e.target.closest('.ybp-add'); if (!sel || !sel.value) return;
const chips = sel.parentNode.querySelector('.ybp-chips');
if (!chips.querySelector('input[value="' + sel.value + '"]')) {
chips.insertAdjacentHTML('beforeend', chipHTML(sel.value, chips.dataset.row, sel.options[sel.selectedIndex].text));
}
sel.value = '';
});
document.addEventListener('click', e => {
const x = e.target.closest('.ybp-chip__x'); if (x) { x.closest('.ybp-chip').remove(); return; }
const b = e.target.closest('[data-act]'); if (!b) return;
const row = b.closest('.ybp-row');
if (b.dataset.act === 'del' && confirm('Supprimer cette compétence ?')) row.remove();
if (b.dataset.act === 'up' && row.previousElementSibling) row.parentNode.insertBefore(row, row.previousElementSibling);
if (b.dataset.act === 'down' && row.nextElementSibling) row.parentNode.insertBefore(row.nextElementSibling, row);
});
/* Glisser-déposer des pastilles pour changer l'ordre (la première = carte de devant) */
let drag = null;
document.addEventListener('dragstart', e => { drag = e.target.closest('.ybp-chip'); if (drag) drag.classList.add('is-drag'); });
document.addEventListener('dragend', () => { if (drag) drag.classList.remove('is-drag'); drag = null; });
document.addEventListener('dragover', e => {
if (!drag) return;
const over = e.target.closest('.ybp-chip'), box = e.target.closest('.ybp-chips');
if (!box || box !== drag.parentNode) return;
e.preventDefault();
if (over && over !== drag) {
const r = over.getBoundingClientRect();
box.insertBefore(drag, (e.clientX - r.left) > r.width / 2 ? over.nextSibling : over);
}
});
})();
</script>
<?php
}
add_action('admin_post_yb_save_proofs', function () {
if (!current_user_can('edit_pages')) wp_die('Accès refusé.');
check_admin_referer('yb_save_proofs');
$clean = array();
foreach ((array) wp_unslash($_POST['yb_proofs'] ?? array()) as $r) {
$skill = sanitize_text_field($r['skill'] ?? '');
$items = array_values(array_unique(array_filter(array_map('intval', (array) ($r['items'] ?? array())))));
if ($skill === '' && !$items) continue;
$clean[] = array('skill' => $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;
});

222
inc/seasons.php Normal file
View file

@ -0,0 +1,222 @@
<?php
/**
* Thèmes saisonniers (fêtes). Réglage : Profil → Saisons (option yb_season : auto | off | <clé>).
* Auto : voir yb2026_season_auto() (dates en heure de Paris).
* Aperçu sans rien enregistrer : ?saison=<clé> | aucune
* Le visiteur peut couper le décor (bouton dans la barre, mémorisé : localStorage yb-season=off).
*/
function yb2026_seasons()
{
$ico = array(
'pumpkin' => '<path d="M12 6.2c-1-.9-2.6-1.2-4-.6C4.6 7 3.4 10.6 4.2 14.2c.7 3.3 3.2 5.6 5.6 5.4.8 0 1.5-.4 2.2-.4s1.4.4 2.2.4c2.4.2 4.9-2.1 5.6-5.4.8-3.6-.4-7.2-3.8-8.6-1.4-.6-3-.3-4 .6z"/><path d="M12 6.2c0-1.4.6-2.6 1.8-3.2"/><path class="st-face" d="M8.3 11.2l1.5-1.6 1.1 1.6zM13.1 11.2l1.1-1.6 1.5 1.6zM8.6 14.6c1 .9 2.1 1.2 3.4 1.2s2.4-.3 3.4-1.2l-1.2.3-.8-.6-.7.7-.7-.7-.7.7-.8-.6z"/>',
'flake' => '<path d="M12 2.5v19M3.8 7.25l16.4 9.5M3.8 16.75l16.4-9.5"/><path d="M9.6 3.9L12 6.2l2.4-2.3M9.6 20.1l2.4-2.3 2.4 2.3M3.9 10.1l3.2.9-.8 3.2M20.1 13.9l-3.2-.9.8-3.2M6.3 9.8l.8 3.2-3.2.9M17.7 14.2l-.8-3.2 3.2-.9"/>',
'burst' => '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8"/><circle class="st-face" cx="12" cy="12" r="1.6"/>',
'clover' => '<path d="M12 12c-2.6-.4-4.6-2.2-4.2-4.3.3-1.5 2-2.2 3.2-1.3.1-1.6 1.8-2.6 3.1-1.8 1.7 1 1.4 4.7-2.1 7.4zM12 12c.4 2.6 2.2 4.6 4.3 4.2 1.5-.3 2.2-2 1.3-3.2 1.6-.1 2.6-1.8 1.8-3.1-1-1.7-4.7-1.4-7.4 2.1zM12 12c-2.6.4-4.6 2.2-4.2 4.3.3 1.5 2 2.2 3.2 1.3"/><path d="M12 12c1.4 3.2 1.3 6.3-.6 9"/>',
'egg' => '<path d="M12 3c3.6 0 6.5 5.2 6.5 10.2 0 4.3-2.9 7.3-6.5 7.3s-6.5-3-6.5-7.3C5.5 8.2 8.4 3 12 3z"/><path d="M6.2 11.6l2.3 1.6 2.3-1.6 2.4 1.6 2.3-1.6 2.3 1.6"/>',
'fish' => '<path d="M3 12c2.8-4 7-5.6 11-4.4 2 .6 3.6 2.4 4.6 4.4-1 2-2.6 3.8-4.6 4.4C10 17.6 5.8 16 3 12z"/><path d="M18.6 12l2.9-3.2v6.4z"/><circle class="st-face" cx="7.4" cy="11.2" r="1"/>',
'note' => '<path d="M9 17.5V6.2l10-2.2v11.3"/><ellipse class="st-face" cx="6.8" cy="17.6" rx="2.4" ry="1.9"/><ellipse class="st-face" cx="16.8" cy="15.4" rx="2.4" ry="1.9"/>',
'cake' => '<path d="M4 20.5h16M5 20.5v-7.2h14v7.2"/><path d="M5 16c1.6 1.2 3 1.2 4.6 0 1.6 1.2 3.2 1.2 4.8 0 1.6 1.2 3 1.2 4.6 0"/><path d="M12 13.3V9.5"/><path class="st-face" d="M12 4.2c1.2 1.4 1.5 2.4 1 3.3-.5.8-1.5.8-2 0-.5-.9-.2-1.9 1-3.3z"/>',
'crescent' => '<path d="M14.6 4.2a8.2 8.2 0 1 0 0 15.6 6.6 6.6 0 1 1 0-15.6z"/><path class="st-face" d="M17.6 9.1l.9 2.1 2.3.1-1.8 1.4.6 2.2-1.9-1.3-1.9 1.3.6-2.2-1.8-1.4 2.3-.1z"/>',
'torii' => '<path d="M3 6.2c3 .9 15 .9 18 0"/><path d="M5.2 9.4h13.6"/><path d="M7 7v13.5M17 7v13.5"/><path d="M12 7v2.4"/>',
'prompt' => '<rect x="2.5" y="4" width="19" height="16" rx="2.5"/><path d="M6.5 9.5l3 2.5-3 2.5"/><path class="st-face" d="M11.5 14h6v1.8h-6z"/>',
'tag' => '<path d="M3.5 12.6V4.5a1 1 0 0 1 1-1h8.1l8 8a1.4 1.4 0 0 1 0 2l-7.1 7.1a1.4 1.4 0 0 1-2 0z"/><circle class="st-face" cx="8" cy="8" r="1.5"/>',
);
$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' => '<svg viewBox="0 0 24 24" aria-hidden="true">' . $ico[$v[1]] . '</svg>',
);
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/<clé>/) */
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 <html> : 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(
'<button type="button" class="season-toggle' . ($s ? '' : ' season-toggle--latent') . '" data-season-toggle data-label-off="%1$s" data-label-on="%2$s" aria-label="%1$s" aria-pressed="true" title="%3$s">%4$s</button>',
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é';
?>
<div class="wrap">
<h1>Thèmes saisonniers</h1>
<?php if (!empty($_GET['updated'])) : ?><div class="notice notice-success is-dismissible"><p>Réglage enregistré.</p></div><?php endif; ?>
<p>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.</p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="yb_save_season">
<?php wp_nonce_field('yb_save_season'); ?>
<fieldset>
<?php foreach ($opts as $k => $l) : ?>
<p><label><input type="radio" name="yb_season" value="<?php echo esc_attr($k); ?>" <?php checked($mode, $k); ?>> <?php echo esc_html($l); ?></label></p>
<?php endforeach; ?>
</fieldset>
<h2>Quantité d'éléments animés</h2>
<p>Combien d'objets apparaissent en fond (étiquettes, ballons, flocons, chauves-souris, feux d'artifice...). 100 % = réglage d'origine, 0 % = aucun.</p>
<?php $dens = yb2026_season_density(); $what = array(
'nouvel-an' => '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'); ?>
<table class="form-table" role="presentation"><tbody>
<?php foreach ($all as $k => $t) : $v = $dens[$k] ?? 100; ?>
<tr>
<th scope="row"><label for="yb-dens-<?php echo esc_attr($k); ?>"><?php echo esc_html($t['label']); ?></label></th>
<td>
<input type="range" id="yb-dens-<?php echo esc_attr($k); ?>" name="yb_season_density[<?php echo esc_attr($k); ?>]" min="0" max="300" step="10" value="<?php echo (int) $v; ?>" style="width:260px;vertical-align:middle" oninput="this.nextElementSibling.textContent=this.value+' %'">
<output style="display:inline-block;min-width:52px;margin-left:8px;font-weight:600"><?php echo (int) $v; ?> %</output>
<span class="description"><?php echo esc_html($what[$k] ?? ''); ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody></table>
<p>Aperçu sans changer le réglage :
<?php foreach ($all as $k => $t) : ?><a href="<?php echo esc_url(add_query_arg('saison', $k, home_url('/'))); ?>" target="_blank"><?php echo esc_html($t['label']); ?></a> · <?php endforeach; ?>
<a href="<?php echo esc_url(add_query_arg('saison', 'aucune', home_url('/'))); ?>" target="_blank">Sans décor</a>
</p>
<?php submit_button('Enregistrer'); ?>
</form>
</div>
<?php
}
add_action('admin_post_yb_save_season', function () {
if (!current_user_can('edit_pages')) wp_die('Accès refusé.');
check_admin_referer('yb_save_season');
$v = sanitize_key($_POST['yb_season'] ?? 'auto');
$dens = array();
foreach ((array) wp_unslash($_POST['yb_season_density'] ?? array()) as $k => $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;
});

173
page-competences.php Normal file
View file

@ -0,0 +1,173 @@
<?php
/* Page « Compétences » (/competences/, ancienne adresse /services/ redirigée en 301) */
get_header();
$services = yb2026_profile('yb_services');
/* Chiffres calculés en direct */
$nb_real = (int) wp_count_posts('realisation')->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.'),
);
?>
<div class="cp">
<!-- ── Ouverture : titre + phrase manifeste aux chiffres animés ─────── -->
<section class="cp-open page-wrap">
<div class="kicker">CE QUE JE SAIS FAIRE</div>
<h1 class="cp-title" data-reveal>COMPÉ<span class="outline-lime">TENCES</span></h1>
<p class="cp-manifest" data-reveal>
J'ai livré <span class="cp-num" data-count="<?php echo (int) $nb_real; ?>" data-pad="2"><?php echo esc_html(str_pad($nb_real, 2, '0', STR_PAD_LEFT)); ?></span> projets,
écrit <span class="cp-num" data-count="<?php echo (int) $nb_posts; ?>" data-pad="2"><?php echo esc_html(str_pad($nb_posts, 2, '0', STR_PAD_LEFT)); ?></span> articles techniques,
et je fais tourner <span class="cp-num" data-count="37">37</span> services en production
depuis <span class="cp-num" data-count="2" data-suffix=" ans">2 ans</span>.
</p>
</section>
<!-- ── Services : lignes éditoriales, rideau lime au survol ─────────── -->
<section class="cp-svc" aria-label="Services">
<?php foreach ($services as $i => $sv) : ?>
<div class="cp-svc__row" data-reveal>
<span class="cp-svc__n"><?php echo esc_html(str_pad($i + 1, 2, '0', STR_PAD_LEFT)); ?></span>
<h2 class="cp-svc__t"><?php echo esc_html($sv['titre']); ?></h2>
<p class="cp-svc__d"><?php echo esc_html($sv['desc']); ?></p>
</div>
<?php endforeach; ?>
</section>
<!-- ── Boîte à outils : un bandeau défilant géant par domaine ───────── -->
<section class="cp-tools" aria-label="Boîte à outils">
<div class="page-wrap cp-head">
<div class="kicker">BOÎTE À OUTILS</div>
<h2 class="cp-h" data-reveal>CE QUE <span class="outline-lime">J'UTILISE</span></h2>
</div>
<?php foreach ($toolbox as $i => $tb) : ?>
<div class="cp-tools__row<?php echo $i % 2 ? ' is-alt' : ''; ?>">
<div class="cp-tools__label"><span class="lime"><?php echo esc_html(str_pad($i + 1, 2, '0', STR_PAD_LEFT)); ?></span> <?php echo esc_html(mb_strtoupper($tb['t'])); ?></div>
<div class="marquee cp-tools__mq" aria-label="<?php echo esc_attr(implode(', ', $tb['i'])); ?>">
<div class="marquee-track<?php echo $i % 2 ? ' marquee-track--reverse' : ''; ?>" style="--dur:<?php echo 26 + $i * 3; ?>s" aria-hidden="true">
<?php for ($k = 0; $k < 2; $k++) : ?>
<span><?php foreach ($tb['i'] as $j => $tool) : ?><b class="<?php echo ($j + $i) % 2 ? 'is-outline' : ''; ?>"><?php echo esc_html(mb_strtoupper($tool)); ?></b><i class="lime">✦</i><?php endforeach; ?></span>
<?php endfor; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</section>
<!-- ── Méthode : numéro géant fixé, étapes qui défilent ─────────────── -->
<section class="cp-method page-wrap" aria-label="Ma méthode">
<div class="cp-method__aside">
<div class="kicker">MA MÉTHODE</div>
<h2 class="cp-h">QUAND ÇA <span class="outline-lime">CASSE</span></h2>
<div class="cp-method__big" aria-hidden="true">
<?php foreach ($method as $i => $m) : ?>
<span class="cp-method__num<?php echo $i === 0 ? ' is-on' : ''; ?>" data-step="<?php echo $i; ?>"><?php echo esc_html(str_pad($i + 1, 2, '0', STR_PAD_LEFT)); ?></span>
<?php endforeach; ?>
</div>
</div>
<ol class="cp-method__steps">
<?php foreach ($method as $i => $m) : ?>
<li class="cp-method__step<?php echo $i === 0 ? ' is-on' : ''; ?>" data-step="<?php echo $i; ?>">
<span class="cp-method__k"><?php echo esc_html(str_pad($i + 1, 2, '0', STR_PAD_LEFT)); ?> / <?php echo esc_html(str_pad(count($method), 2, '0', STR_PAD_LEFT)); ?></span>
<span class="cp-method__t"><?php echo esc_html(mb_strtoupper($m['t'])); ?></span>
<span class="cp-method__d"><?php echo esc_html($m['d']); ?></span>
</li>
<?php endforeach; ?>
</ol>
</section>
<!-- ── Infra servyass (terminal du design d'origine) ────────────────── -->
<section class="page-wrap cp-infra">
<div class="kicker">$ ssh yass@servyass</div>
<h2 class="infra-title" data-reveal>L'INFRA <span class="lime">SERVYASS</span> · EN PROD DEPUIS 2 ANS</h2>
<p class="infra-lead">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.</p>
<div class="infra-cols">
<div class="term" data-reveal>
<div class="term-bar">
<span class="term-dot term-dot--r"></span><span class="term-dot term-dot--y"></span><span class="term-dot term-dot--g"></span>
<span class="term-title">yass@servyass: docker ps</span>
</div>
<div class="term-body">
<div class="term-muted">$ docker ps --format table · NAME · STATUS</div>
<?php foreach ($containers as $c) : ?>
<div class="term-row"><span><?php echo esc_html($c); ?></span><span class="lime">Up (healthy)</span></div>
<?php endforeach; ?>
<div class="term-muted term-foot">$ uptime · ▲ en production depuis 2 ans, maintenu en autonomie</div>
</div>
</div>
<div class="infra-points">
<?php foreach ($infra_points as $ip) : ?>
<div class="ipoint" data-reveal>
<div class="ipoint-title"><?php echo esc_html($ip['t']); ?></div>
<div class="ipoint-desc"><?php echo esc_html($ip['d']); ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="infra-strip">FLUX EN DIRECT · 16+ CONTENEURS · 1 SEUL ADMIN · SUPERVISION UPTIME-KUMA · BACKUPS PLANIFIÉS · MÀJ SÉCU HEBDO</div>
</section>
<!-- ── Preuves : survol d'une compétence = vignette du projet qui suit la souris ── -->
<?php if ($proofs) : ?>
<section class="cp-proofs" aria-label="Preuves">
<div class="page-wrap cp-head">
<div class="kicker">PREUVES À L'APPUI · PROJETS &amp; ARTICLES</div>
<h2 class="cp-h" data-reveal>CHAQUE COMPÉTENCE <span class="outline-lime">A SON PROJET</span></h2>
</div>
<ul class="cp-proofs__list">
<?php foreach ($proofs as $skill => $links) :
$first = $links[0]; ?>
<li class="cp-proof" data-thumb="<?php echo esc_url($first['thumb']); ?>" data-name="<?php echo esc_attr($first['name']); ?>" data-cursor-label="<?php echo count($links) > 1 ? 'OUVRIR LA ROUE' : ($first['kind'] === 'article' ? 'LIRE L\'ARTICLE' : 'VOIR LE PROJET'); ?>">
<span class="cp-proof__skill"><?php echo esc_html($skill); ?></span>
<span class="cp-proof__links">
<?php foreach ($links as $l) : ?>
<a href="<?php echo esc_url(get_permalink($l['post'])); ?>" class="is-<?php echo esc_attr($l['kind']); ?>" data-wipe="<?php echo esc_attr($l['wipe']); ?>" data-cursor-label="<?php echo $l['kind'] === 'article' ? 'LIRE L\'ARTICLE' : 'VOIR LE PROJET'; ?>" data-thumb="<?php echo esc_url($l['thumb']); ?>" data-name="<?php echo esc_attr($l['name']); ?>" data-kind="<?php echo esc_attr($l['kind']); ?>" data-tag="<?php echo esc_attr($l['tag']); ?>"><?php if ($l['kind'] === 'article') : ?><span class="cp-proof__kind">ARTICLE</span> <?php endif; ?><?php echo esc_html($l['name']); ?> <span class="lime">→</span></a>
<?php endforeach; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<div class="cp-preview" aria-hidden="true"></div>
<div class="cp-wheel" hidden role="dialog" aria-label="Choisir un projet">
<div class="cp-wheel__svg" aria-hidden="true"></div>
<div class="cp-wheel__center"><span class="cp-wheel__skill"></span><span class="cp-wheel__name"></span><span class="cp-wheel__hint"></span></div>
</div>
</section>
<?php endif; ?>
<!-- ── Fin : appel à l'échange, comme sur l'accueil ─────────────────── -->
<section class="cta">
<div class="kicker" data-reveal><?php echo esc_html(mb_strtoupper(yb2026_id('accroche'))); ?></div>
<a class="cta__title" href="<?php echo esc_url(home_url('/contact/')); ?>" data-wipe="CONTACT" data-cursor-label="M'ÉCRIRE" data-reveal>TRAVAILLONS<br><span class="outline-lime">ENSEMBLE →</span></a>
</section>
</div>
<?php get_footer(); ?>

17
page-realisation.php Normal file
View file

@ -0,0 +1,17 @@
<?php
/* Page « Réalisations » (slug /realisation/) : grille des projets */
get_header();
$projects = yb2026_get_projects();
?>
<div class="page-wrap">
<div class="kicker">PROJETS · FORMATION &amp; CONTEXTE PRO</div>
<h1 class="page-title" data-reveal>MES<br><span class="outline-lime">RÉALISATIONS</span></h1>
<p class="page-lead">Du développement web à l'administration d'infrastructures. Cliquer sur un projet ouvre l'étude de cas.</p>
<div class="real-grid grid-auto">
<?php foreach ($projects as $p) yb2026_project_card($p); ?>
</div>
</div>
<?php get_footer(); ?>

50
single.php Normal file
View file

@ -0,0 +1,50 @@
<?php
/* Article de blog */
get_header();
the_post();
$next = get_next_post();
if (!$next) $next = get_previous_post();
?>
<article class="case">
<?php if (has_post_thumbnail()) : ?>
<div class="case-hero">
<?php the_post_thumbnail('full', array('class' => 'case-hero-img')); ?>
<div class="case-hero-fade"></div>
<div class="case-hero-text">
<div class="kicker rise rise-1">JOURNAL TECHNIQUE · <?php echo esc_html(strtoupper(get_the_date('j M Y'))); ?></div>
<h1 class="case-title case-title--post rise rise-2"><?php the_title(); ?></h1>
</div>
</div>
<?php else : ?>
<div class="page-wrap page-wrap--tight">
<div class="kicker">JOURNAL TECHNIQUE · <?php echo esc_html(strtoupper(get_the_date('j M Y'))); ?></div>
<h1 class="page-title" data-reveal><?php the_title(); ?></h1>
</div>
<?php endif; ?>
<div class="case-cols">
<aside class="case-meta article__meta">
<div><div class="case-meta-k">PUBLIÉ</div><div><?php echo esc_html(get_the_date('j F Y')); ?></div></div>
<?php $cats = get_the_category(); if ($cats) : ?>
<div><div class="case-meta-k">CATÉGORIE</div><div class="lime"><?php echo esc_html(strtoupper($cats[0]->name)); ?></div></div>
<?php endif; ?>
</aside>
<div class="case-body">
<div class="prose"><?php the_content(); ?></div>
</div>
</div>
<?php if ($next) : ?>
<a class="case-next" href="<?php echo esc_url(get_permalink($next)); ?>" data-cursor-label="LIRE ↗" data-wipe="LECTURE">
<div>
<div class="case-next-k mono">ARTICLE SUIVANT</div>
<div class="case-next-name"><?php echo esc_html(get_the_title($next)); ?></div>
</div>
<div class="case-next-arrow mono">→</div>
</a>
<?php endif; ?>
</article>
<?php get_footer(); ?>