24 Commits

Author SHA1 Message Date
4ae9b67b9c chore(release): v0.1.5 2026-01-21 22:05:06 -05:00
3b706c0092 fix: type issue with NodePage 2026-01-21 22:04:46 -05:00
baa3061685 fix: immutable extractNodes 2026-01-21 21:58:35 -05:00
fd61895bbd fix: auth server utils upgrade to latest nuxt-graphql 2026-01-21 21:52:53 -05:00
cdcb09e24b chore: update nuxt-graphql 2026-01-21 20:37:15 -05:00
341b0d6e9d chore(release): v0.1.4 2026-01-20 11:13:47 -05:00
58d1dc0045 chore(release): v0.1.3 2026-01-20 11:13:13 -05:00
5e0df227f3 feat: hide title on front page 2026-01-20 11:13:01 -05:00
2d0b176ab8 feat: login / logout toast 2026-01-20 10:54:13 -05:00
bfb5ae3a70 fix: fatal 404 2026-01-20 10:38:47 -05:00
9d99770b38 refactor: /api/login route 2026-01-20 10:37:29 -05:00
e383255e73 refactor: update to nuxt-graphql 0.5.x 2026-01-20 10:09:44 -05:00
684e2fa1e9 chore: update deps 2026-01-20 09:33:47 -05:00
8e26f19f66 feat: TinyMCE WYSIWYG editor styles 2026-01-13 22:43:25 -05:00
40becf1135 feat: UiProse prose component with link highjacking 2026-01-13 22:19:23 -05:00
764bc6aeea feat: Initial typography / prose styles 2026-01-13 22:17:15 -05:00
12048ffdd3 feat: LayoutContained section wrapper 2026-01-13 22:07:59 -05:00
c7f6cca663 feat: LaoutContained 2026-01-13 21:51:18 -05:00
2b9a87511b feat: BuilderSections component 2026-01-13 21:36:26 -05:00
688c4e36b3 feat: Initial NodeByUri logic and frontend 2026-01-13 21:25:20 -05:00
5bda835566 chore(release): v0.1.2 2026-01-13 21:17:37 -05:00
6f6e0d7b76 chore: update @lewebsimple/nuxt-graphql 2026-01-13 21:17:23 -05:00
c1094239a3 feat: Initial authentication logic and UX 2026-01-13 21:07:40 -05:00
f9958701e6 chore: pnpm approve builds 2026-01-13 11:32:55 -05:00
57 changed files with 3528 additions and 2142 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "lewebsimple/wp-headless", "name": "lewebsimple/wp-headless",
"description": "WordPress project", "description": "WP Headless",
"version": "0.4.25", "version": "0.4.25",
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",

12
composer.lock generated
View File

@@ -205,15 +205,15 @@
}, },
{ {
"name": "wpackagist-plugin/acf-extended", "name": "wpackagist-plugin/acf-extended",
"version": "0.9.2.2", "version": "0.9.2.3",
"source": { "source": {
"type": "svn", "type": "svn",
"url": "https://plugins.svn.wordpress.org/acf-extended/", "url": "https://plugins.svn.wordpress.org/acf-extended/",
"reference": "tags/0.9.2.2" "reference": "tags/0.9.2.3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://downloads.wordpress.org/plugin/acf-extended.0.9.2.2.zip" "url": "https://downloads.wordpress.org/plugin/acf-extended.0.9.2.3.zip"
}, },
"require": { "require": {
"composer/installers": "^1.0 || ^2.0" "composer/installers": "^1.0 || ^2.0"
@@ -241,15 +241,15 @@
}, },
{ {
"name": "wpackagist-plugin/disable-comments", "name": "wpackagist-plugin/disable-comments",
"version": "2.6.1", "version": "2.6.2",
"source": { "source": {
"type": "svn", "type": "svn",
"url": "https://plugins.svn.wordpress.org/disable-comments/", "url": "https://plugins.svn.wordpress.org/disable-comments/",
"reference": "tags/2.6.1" "reference": "tags/2.6.2"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://downloads.wordpress.org/plugin/disable-comments.2.6.1.zip" "url": "https://downloads.wordpress.org/plugin/disable-comments.2.6.2.zip"
}, },
"require": { "require": {
"composer/installers": "^1.0 || ^2.0" "composer/installers": "^1.0 || ^2.0"

View File

@@ -1,5 +1,80 @@
# Changelog # Changelog
## v0.1.5
[compare changes](https://gitea.websimple.com/templates/wp-headless/compare/v0.1.4...v0.1.5)
### 🩹 Fixes
- Auth server utils upgrade to latest nuxt-graphql (fd61895)
- Immutable extractNodes (baa3061)
- Type issue with NodePage (3b706c0)
## v0.1.4
[compare changes](https://gitea.websimple.com/templates/wp-headless/compare/v0.1.2...v0.1.4)
### 🚀 Enhancements
- Initial NodeByUri logic and frontend (688c4e3)
- BuilderSections component (2b9a875)
- LaoutContained (c7f6cca)
- LayoutContained section wrapper (12048ff)
- Initial typography / prose styles (764bc6a)
- UiProse prose component with link highjacking (40becf1)
- TinyMCE WYSIWYG editor styles (8e26f19)
- Login / logout toast (2d0b176)
- Hide title on front page (5e0df22)
### 🩹 Fixes
- Fatal 404 (bfb5ae3)
### 💅 Refactors
- Update to nuxt-graphql 0.5.x (e383255)
- /api/login route (9d99770)
## v0.1.3
[compare changes](https://gitea.websimple.com/templates/wp-headless/compare/v0.1.2...v0.1.3)
### 🚀 Enhancements
- Initial NodeByUri logic and frontend (688c4e3)
- BuilderSections component (2b9a875)
- LaoutContained (c7f6cca)
- LayoutContained section wrapper (12048ff)
- Initial typography / prose styles (764bc6a)
- UiProse prose component with link highjacking (40becf1)
- TinyMCE WYSIWYG editor styles (8e26f19)
- Login / logout toast (2d0b176)
- Hide title on front page (5e0df22)
### 🩹 Fixes
- Fatal 404 (bfb5ae3)
### 💅 Refactors
- Update to nuxt-graphql 0.5.x (e383255)
- /api/login route (9d99770)
## v0.1.2
[compare changes](https://gitea.websimple.com/templates/wp-headless/compare/v0.1.1...v0.1.2)
### 🚀 Enhancements
- Initial Nuxt UI configuration (ca2e660)
- Initial layout with SiteHeader / SiteFooter (3d7a2b2)
- Update .gitignore and add Copilot instructions (f520db7)
- Optional SSL for dev server (9b6a86f)
- Typecheck npm script (33589d4)
- Initial theme setup (theme features, locale, main menu) (a286047)
- Initial GraphQL setup with remote WP schema (d0244eb)
- Initial authentication logic and UX (c109423)
## v0.1.1 ## v0.1.1

View File

@@ -0,0 +1,156 @@
{
"key": "group_abstract_builder",
"title": "Abstract - Builder",
"fields": [
{
"key": "field_builder_sections",
"label": "Section(s)",
"name": "sections",
"aria-label": "",
"type": "flexible_content",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"acfe_flexible_advanced": 1,
"acfe_flexible_stylised_button": 0,
"acfe_flexible_hide_empty_message": 0,
"acfe_flexible_empty_message": "",
"acfe_flexible_layouts_templates": 0,
"acfe_flexible_layouts_placeholder": 0,
"acfe_flexible_layouts_thumbnails": 0,
"acfe_flexible_async": [],
"acfe_flexible_add_actions": [
"copy",
"title",
"toggle"
],
"acfe_flexible_remove_button": [],
"acfe_flexible_remove_top_actions": [],
"acfe_flexible_modal_edit": {
"acfe_flexible_modal_edit_enabled": "1",
"acfe_flexible_modal_edit_size": "xlarge"
},
"acfe_flexible_modal": {
"acfe_flexible_modal_enabled": "0",
"acfe_flexible_modal_title": false,
"acfe_flexible_modal_size": "xlarge",
"acfe_flexible_modal_col": "4",
"acfe_flexible_modal_categories": false
},
"acfe_flexible_modal_settings": {
"acfe_flexible_modal_settings_enabled": "1",
"acfe_flexible_modal_settings_size": "large",
"acfe_flexible_modal_settings_close": "1",
"acfe_flexible_modal_settings_close_label": ""
},
"layouts": {
"layout_6852f761e95b0": {
"key": "layout_6852f761e95b0",
"name": "text_block",
"label": "Bloc de texte",
"display": "block",
"sub_fields": [
{
"key": "field_68eeceb62b8a6",
"label": "Contenu",
"name": "content",
"aria-label": "",
"type": "wysiwyg",
"instructions": "",
"required": 1,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"acfe_wysiwyg_height": 300,
"acfe_wysiwyg_max_height": "",
"acfe_wysiwyg_valid_elements": "",
"acfe_wysiwyg_custom_style": "",
"acfe_wysiwyg_disable_wp_style": 0,
"acfe_wysiwyg_autoresize": 0,
"acfe_wysiwyg_disable_resize": 0,
"acfe_wysiwyg_remove_path": 0,
"acfe_wysiwyg_menubar": 0,
"acfe_wysiwyg_transparent": 0,
"acfe_wysiwyg_merge_toolbar": 0,
"acfe_wysiwyg_custom_toolbar": 0,
"required_message": "",
"allow_in_bindings": 0,
"tabs": "all",
"toolbar": "full",
"media_upload": 1,
"delay": 0,
"show_in_graphql": 1,
"graphql_description": "",
"graphql_field_name": "content",
"graphql_non_null": 1,
"acfe_wysiwyg_auto_init": 0,
"acfe_wysiwyg_min_height": 300,
"acfe_wysiwyg_toolbar_buttons": []
}
],
"min": "",
"max": "",
"acfe_flexible_modal_edit_size": "",
"acfe_flexible_settings": [
"group_layout_contained"
],
"acfe_flexible_settings_size": "large",
"acfe_flexible_render_template": false,
"acfe_flexible_render_style": false,
"acfe_flexible_render_script": false,
"acfe_flexible_thumbnail": false,
"acfe_flexible_category": false
}
},
"min": "",
"max": "",
"button_label": "Ajouter un élément",
"show_in_graphql": 1,
"graphql_description": "",
"graphql_field_name": "sections",
"graphql_non_null": 0,
"acfe_flexible_layouts_previews": false,
"acfe_flexible_close_button_label": "",
"acfe_flexible_layouts_state": false
}
],
"location": [
[
{
"param": "abstract"
}
]
],
"menu_order": 0,
"position": "acf_after_title",
"style": "seamless",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": [
"the_content"
],
"active": true,
"description": "",
"show_in_rest": 0,
"display_title": "",
"acfe_autosync": [
"json"
],
"acfe_form": 0,
"show_in_graphql": 1,
"graphql_field_name": "GroupAbstractBuilder",
"map_graphql_types_from_location_rules": 0,
"graphql_types": "",
"acfe_meta": "",
"acfe_note": "",
"modified": 1768358815
}

View File

@@ -0,0 +1,149 @@
{
"key": "group_layout_contained",
"title": "Layout - Contained",
"fields": [
{
"key": "field_68dc29d78941c",
"label": "Conteneur",
"name": "container",
"aria-label": "",
"type": "select",
"instructions": "",
"required": 1,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"choices": {
"default": "1536px",
"xl": "1280px",
"lg": "1024px",
"fluid": "Largeur fluide",
"none": "Pleine largeur"
},
"default_value": "default",
"return_format": "value",
"multiple": 0,
"max": "",
"prepend": "",
"append": "",
"required_message": "",
"allow_null": 0,
"allow_in_bindings": 0,
"ui": 0,
"show_in_graphql": 1,
"graphql_description": "",
"graphql_field_name": "container",
"graphql_non_null": 1,
"ajax": 0,
"placeholder": "",
"create_options": 0,
"save_options": 0,
"allow_custom": 0,
"search_placeholder": "",
"min": ""
},
{
"key": "field_693c8c3b5ce50",
"label": "Espacement vertical",
"name": "vertical_padding",
"aria-label": "",
"type": "select",
"instructions": "",
"required": 1,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"choices": {
"sm": "Petit (12px)",
"md": "Medium (24px)",
"lg": "Grand (48px)"
},
"default_value": "md",
"return_format": "value",
"multiple": 0,
"allow_null": 0,
"allow_in_bindings": 0,
"ui": 0,
"show_in_graphql": 1,
"graphql_description": "",
"graphql_field_name": "verticalPadding",
"graphql_non_null": 1,
"ajax": 0,
"placeholder": "",
"create_options": 0,
"save_options": 0,
"allow_custom": 0,
"search_placeholder": ""
},
{
"key": "field_693c8c945ce51",
"label": "Couleur d'arrière-plan",
"name": "bg_color",
"aria-label": "",
"type": "select",
"instructions": "",
"required": 1,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"choices": {
"default": "Par défaut",
"muted": "Atténué",
"inverted": "Inversé"
},
"default_value": "default",
"return_format": "value",
"multiple": 0,
"allow_null": 0,
"allow_in_bindings": 0,
"ui": 0,
"show_in_graphql": 1,
"graphql_description": "",
"graphql_field_name": "bgColor",
"graphql_non_null": 1,
"ajax": 0,
"placeholder": "",
"create_options": 0,
"save_options": 0,
"allow_custom": 0,
"search_placeholder": ""
}
],
"location": [
[
{
"param": "abstract"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "",
"show_in_rest": 0,
"display_title": "",
"acfe_autosync": [
"json"
],
"acfe_form": 0,
"show_in_graphql": 1,
"graphql_field_name": "GroupLayoutContained",
"map_graphql_types_from_location_rules": 0,
"graphql_types": "",
"acfe_meta": "",
"acfe_note": "",
"modified": 1768358794
}

View File

@@ -0,0 +1,66 @@
{
"key": "group_post_page",
"title": "Post - Page",
"fields": [
{
"key": "field_690cbda0abcbb",
"label": "Constructeur de page",
"name": "builder",
"aria-label": "",
"type": "clone",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"graphql_field_name": "builder",
"clone": [
"group_abstract_builder"
],
"display": "seamless",
"layout": "block",
"prefix_label": 0,
"prefix_name": 0,
"acfe_seamless_style": 0,
"acfe_clone_modal": 0,
"acfe_clone_modal_close": 0,
"acfe_clone_modal_button": "",
"acfe_clone_modal_size": "large"
}
],
"location": [
[
{
"param": "post_type",
"operator": "==",
"value": "page"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "seamless",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": [
"the_content"
],
"active": true,
"description": "",
"show_in_rest": 0,
"display_title": "",
"acfe_autosync": [
"json"
],
"acfe_form": 0,
"show_in_graphql": 1,
"graphql_field_name": "GroupPostPage",
"map_graphql_types_from_location_rules": 0,
"graphql_types": "",
"acfe_meta": "",
"acfe_note": "",
"modified": 1768336934
}

View File

@@ -0,0 +1,13 @@
export default defineAppConfig({
ui: {
colors: {
primary: "indigo",
neutral: "neutral",
},
button: {
slots: {
base: "cursor-pointer",
},
},
},
});

View File

@@ -1,2 +1,10 @@
@import "tailwindcss"; @import "tailwindcss" theme(static) source("../../..");
@import "@nuxt/ui"; @import "@nuxt/ui";
@import "./a11y.css";
@import "./containers.css";
@import "./links.css";
@import "./prose.css";
@import "./typography.css";
@import "./vendors/tinymce.css";

View File

@@ -0,0 +1,7 @@
@utility disabled-default {
@apply disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75;
}
@utility focus-default {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2;
}

View File

@@ -0,0 +1,47 @@
:root {
--ui-container: var(--breakpoint-2xl);
}
/* Container padding */
@utility px-container {
@apply px-4 sm:px-6 lg:px-8;
}
/* Container sizes */
@utility container { @apply mx-auto px-container max-w-(--breakpoint-2xl); }
@utility container-xl { @apply container max-w-(--breakpoint-xl); }
@utility container-lg { @apply container max-w-(--breakpoint-lg); }
@utility container-md { @apply container max-w-(--breakpoint-md); }
@utility container-sm { @apply container max-w-(--breakpoint-sm); }
@utility container-fluid { @apply container max-w-screen; }
@utility container-none { @apply w-full max-w-screen; }
/* Split containers */
:root {
--container-outside-margin: 0;
--container-width: 100vw;
@variant sm {
--container-outside-margin: calc(50vw - theme("screens.sm") / 2);
--container-width: theme("screens.sm");
}
@variant md {
--container-outside-margin: calc(50vw - theme("screens.md") / 2);
--container-width: theme("screens.md");
}
@variant lg {
--container-outside-margin: calc(50vw - theme("screens.lg") / 2);
--container-width: theme("screens.lg");
}
@variant xl {
--container-outside-margin: calc(50vw - theme("screens.xl") / 2);
--container-width: theme("screens.xl");
}
@variant 2xl {
--container-outside-margin: calc(50vw - theme("screens.2xl") / 2);
--container-width: theme("screens.2xl");
}
}
@utility container-left { @apply ml-(--container-outside-margin) px-container;}
@utility container-right { @apply mr-(--container-outside-margin) px-container;}
@utility container-half { width: calc(var(--container-width) / 2);}

View File

@@ -0,0 +1,7 @@
/* Variant to target all children links without specific link or button classes */
@custom-variant links (& a:not([class*='link-']):not([class*='button-']));
/* Link styles */
@utility link-base { @apply cursor-pointer disabled-default transition; }
@utility link-underline { @apply link-base underline hover:decoration-primary; }
@utility link-opacity { @apply link-base hover:opacity-80; }

View File

@@ -0,0 +1,16 @@
@utility prose {
/* Headings (allow class overrides) */
h1:not([class*="heading-"]) { @apply heading-1; }
h2:not([class*="heading-"]) { @apply heading-2; }
h3:not([class*="heading-"]) { @apply heading-3; }
h4:not([class*="heading-"]) { @apply heading-4; }
/* Links */
@apply links:link-underline;
/* Paragraphs */
p:not([class*="paragraph-"]) { @apply paragraph-base; }
/* Spacing */
@apply space-y-2;
}

View File

@@ -0,0 +1,10 @@
/* Heading styles */
@utility heading-base { @apply font-bold tracking-tight };
@utility heading-1 { @apply heading-base text-4xl; }
@utility heading-2 { @apply heading-base text-3xl; }
@utility heading-3 { @apply heading-base text-2xl; }
@utility heading-4 { @apply heading-base text-xl; }
/* Paragraph styles */
@utility paragraph-base { @apply font-sans; }
@utility paragraph-lead { @apply paragraph-base text-2xl; }

View File

@@ -0,0 +1,3 @@
body#tinymce {
@apply prose;
}

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
const { isLoggedIn } = useAuth();
const attrs = computed(() => {
return isLoggedIn.value
? {
label: "Déconnexion",
icon: "i-lucide-log-out",
}
: {
label: "Connexion",
icon: "i-lucide-log-in",
};
});
</script>
<template>
<AuthState>
<UButton to="/connexion" v-bind="attrs" color="neutral" />
</AuthState>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
const { login } = useAuthConnexion();
const fields = [
{
name: "username",
type: "text" as const,
label: "Courriel",
placeholder: "Entrez votre courriel",
required: true,
}, {
name: "password",
label: "Mot de passe",
type: "password" as const,
placeholder: "Entrez votre mot de passe",
required: true,
},
];
</script>
<template>
<UAuthForm
:schema="authLoginFormSchema"
:fields="fields"
title="Connexion"
description="Veuillez vous identifier."
loading-auto
@submit="login"
/>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
const { logout } = useAuthConnexion();
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-col text-center">
<div class="text-xl text-pretty font-semibold text-highlighted">
Déconnexion
</div>
<div class="mt-1 text-base text-pretty text-muted">
Veuillez confirmer la déconnexion.
</div>
</div>
<UButton
icon="i-lucide-log-out"
block
loading-auto
to="#"
label="Déconnexion"
@click="logout()"
/>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<template>
<div class="w-full space-y-6">
<div class="flex flex-col text-center">
<div class="text-xl text-pretty font-semibold text-highlighted">
Redirection en cours
</div>
<div class="mt-1 text-base text-pretty text-muted">
Veuillez patienter...
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,8 @@
fragment BuilderSections on GroupAbstractBuilder_Fields {
sections {
__typename
... on GroupAbstractBuilderSectionsTextBlockLayout {
... SectionTextBlock
}
}
}

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { BuilderSectionsFragment } from "#graphql/operations";
const props = defineProps<BuilderSectionsFragment>();
const sections = computed(() => {
return (props.sections || [])
.filter((section) => !!section)
.map(({ __typename, ...attrs }) => ({
componentName: __typename.replace(/^GroupAbstractBuilderSections(.+?)Layout$/, "Section$1"),
attrs,
}));
});
</script>
<template>
<div id="builder-sections">
<Component
:is="componentName"
v-for="({ componentName, attrs }, index) in sections"
:key="index"
v-bind="attrs"
/>
</div>
</template>

View File

@@ -0,0 +1,5 @@
fragment LayoutContained on GroupLayoutContained_Fields {
container
verticalPadding
bgColor
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { LayoutContainedFragment } from "#graphql/operations";
import { tv, type VariantProps } from "tailwind-variants";
const props = defineProps<LayoutContainedFragment>();
const layoutWrapperVariants = tv({
slots: {
base: "",
inner: "",
},
variants: {
container: {
default: { inner: "container" },
lg: { inner: "container-lg" },
xl: { inner: "container-xl" },
fluid: { inner: "container-fluid" },
none: { inner: "container-none" },
},
verticalPadding: {
sm: { base: "py-3" },
md: { base: "py-6" },
lg: { base: "py-12" },
},
bgColor: {
default: { base: "bg-default" },
muted: { base: "bg-muted" },
inverted: { base: "bg-inverted text-inverted" },
},
},
defaultVariants: {
container: "default",
verticalPadding: "md",
bgColor: "default",
},
});
const { base, inner } = layoutWrapperVariants({
container: props.container[0],
verticalPadding: props.verticalPadding[0],
bgColor: props.bgColor[0],
} as VariantProps<typeof layoutWrapperVariants>);
</script>
<template>
<section :class="base()">
<div :class="inner()">
<slot />
</div>
</section>
</template>

View File

@@ -0,0 +1,7 @@
fragment NodePage on Page {
title
isFrontPage
groupPostPage {
... BuilderSections
}
}

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { NodePageFragment } from "#graphql/operations";
defineProps<NodePageFragment>();
</script>
<template>
<div id="node-page">
<h1 v-if="!isFrontPage" class="font-bold text-4xl">
{{ title }}
</h1>
<BuilderSections :sections="groupPostPage?.sections || []" />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const { isLoggedIn } = useAuth();
const { isRedirecting } = useAuthConnexion();
</script>
<template>
<section data-section-name="auth-connexion" class="py-12">
<div class="container-sm">
<AuthState>
<AuthRedirecting v-if="isRedirecting" />
<template v-else>
<AuthLogoutForm v-if="isLoggedIn" />
<AuthLoginForm v-else />
</template>
</AuthState>
</div>
</section>
</template>

View File

@@ -0,0 +1,6 @@
fragment SectionTextBlock on GroupAbstractBuilderSectionsTextBlockLayout {
content
layoutSettings {
...LayoutContained
}
}

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type { SectionTextBlockFragment } from "#graphql/operations";
defineProps<SectionTextBlockFragment>();
</script>
<template>
<LayoutContained data-section-type="text-block" v-bind="layoutSettings!">
<UiProse :content="content" />
</LayoutContained>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { data } = await useGraphQLQuery("GeneralSettings", undefined, { cache: { ttl: 0 } }); const { data } = await useAsyncGraphQLQuery("GeneralSettings", undefined, { cache: { ttl: 0 } });
</script> </script>
<template> <template>

View File

@@ -3,5 +3,9 @@ const title = "Moonshine";
</script> </script>
<template> <template>
<UHeader :title="title" /> <UHeader :title="title">
<template #right>
<AuthConnexionButton />
</template>
</UHeader>
</template> </template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{ content: string }>();
const refContent = useTemplateRef("refContent");
useProseLinks(refContent);
</script>
<template>
<div ref="refContent" class="prose" v-html="content" />
</template>

View File

@@ -0,0 +1,6 @@
export function useAuth() {
const { loggedIn: isLoggedIn, session } = useUserSession();
const hasRole = (role: string) => session.value?.user?.roles?.includes(role) || false;
const isAdmin = computed(() => hasRole("administrator"));
return { isLoggedIn, hasRole, isAdmin };
}

View File

@@ -0,0 +1,71 @@
import type { FormSubmitEvent } from "@nuxt/ui";
const isRedirecting = ref(false);
export function useAuthConnexion() {
const toast = useToast();
const { fetch: refreshUserSession } = useUserSession();
const routeRedirect = useRoute().query.redirect as string || undefined;
// Helper: Redirect after login / logout
async function redirectTo(to: string | undefined) {
isRedirecting.value = true;
await delay(1000);
await refreshUserSession();
await navigateTo(to || routeRedirect || "/");
}
// Login
async function login({ data: body }: FormSubmitEvent<AuthLoginForm>, redirect?: string) {
try {
const { success, message } = await $fetch("/api/login", { method: "POST", body });
if (!success) {
throw new Error(message);
}
toast.add({
title: "Connexion réussie",
color: "success",
description: message,
duration: 3000,
});
await redirectTo(redirect);
}
catch (error) {
console.log(error);
toast.add({
title: "Erreur de connexion",
color: "error",
description: error instanceof Error ? error.message : "Une erreur est survenue lors de la connexion.",
duration: 5000,
});
}
}
// Logout
async function logout(redirect?: string) {
try {
const result = await $fetch("/api/logout", { method: "POST" });
if (!result.success) {
throw new Error("Échec de la déconnexion.");
}
toast.add({
title: "Déconnexion réussie",
color: "success",
description: "Vous avez été déconnecté avec succès.",
duration: 3000,
});
await redirectTo(redirect);
}
catch (error) {
console.log(error);
toast.add({
title: "Erreur de déconnexion",
color: "error",
description: error instanceof Error ? error.message : "Une erreur est survenue lors de la déconnexion.",
duration: 5000,
});
}
}
return { isRedirecting, login, logout };
}

View File

@@ -0,0 +1,65 @@
export function useProseLinks(refContent: Ref<HTMLElement | null>) {
const router = useRouter();
const { url } = useSiteConfig();
const siteUrl = new URL(url);
// Determine if the href is internal
const isInternal = (href: string) => {
if (!href) return false;
if (href.startsWith("/")) return true;
if (href.startsWith("#")) return false;
try {
const hrefUrl = new URL(href);
return hrefUrl.hostname === siteUrl.hostname;
}
catch {
return false;
}
};
// Convert href to relative path
const convertToRelative = (href: string) => {
if (href.startsWith("/")) return href;
try {
const hrefUrl = new URL(href);
if (hrefUrl.hostname === siteUrl.hostname) {
return hrefUrl.pathname + hrefUrl.search + hrefUrl.hash;
}
}
catch {
// Invalid URL
}
return href;
};
// Highjack click events to use router for internal links
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest("a");
if (!link) return;
const href = link.getAttribute("href");
if (!href) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || link.target === "_blank" || link.hasAttribute("download")) {
return;
}
if (isInternal(href)) {
e.preventDefault();
const path = convertToRelative(href);
router.push(path);
}
};
// Attach and detach event listeners
onMounted(() => {
const element = unref(refContent);
if (element) {
element.addEventListener("click", handleClick);
}
});
onBeforeUnmount(() => {
const element = unref(refContent);
if (element) {
element.removeEventListener("click", handleClick);
}
});
}

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { fr } from "@nuxt/ui/locale";
import type { NuxtError } from "#app";
const props = defineProps<{ error: NuxtError }>();
const formattedError = computed(() => {
const error = {
statusCode: props.error.statusCode,
statusMessage: props.error.statusMessage,
message: props.error.message,
};
switch (error.statusCode) {
case 404:
error.statusMessage = "Page non trouvée";
break;
case 500:
error.message = "Erreur interne du serveur.";
break;
}
return error;
});
</script>
<template>
<UApp :locale="fr">
<UError :error="formattedError" />
</UApp>
</template>

View File

@@ -0,0 +1,19 @@
fragment AuthUser on User {
id
email
roles {
nodes {
name
}
}
}
mutation AuthLogin($username: String!, $password: String!) {
login( input: { provider: PASSWORD, credentials: { username: $username, password: $password }}) {
authToken
refreshToken
user {
...AuthUser
}
}
}

View File

@@ -0,0 +1,5 @@
mutation AuthRefreshToken($refreshToken: String!) {
refreshToken( input: { refreshToken: $refreshToken }) {
authToken
}
}

View File

@@ -0,0 +1,8 @@
query NodeByUri($uri: String!) {
nodeByUri(uri: $uri) {
__typename
... on Page {
... NodePage
}
}
}

View File

@@ -1,6 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
const { path: uri } = useRoute();
const { data } = await useAsyncGraphQLQuery("NodeByUri", { uri });
// Resolve and validate Node component
if (!data.value.nodeByUri) {
throw createError({ statusCode: 404, message: `La page demandée est introuvable: ${uri}`, fatal: true });
}
const componentName = `Node${data.value.nodeByUri.__typename}`;
if (!useNuxtApp().vueApp.component(componentName)) {
throw createError({ statusCode: 404, message: `La page demandée ne peut pas être affichée correctement: ${componentName}`, fatal: true });
}
</script> </script>
<template> <template>
<div id="page-node-from-uri" /> <div v-if="data.nodeByUri" id="page-node-from-uri">
<Component :is="componentName" v-bind="data.nodeByUri" />
</div>
</template> </template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
const { isRedirecting } = useAuthConnexion();
onBeforeMount(() => {
isRedirecting.value = false;
});
</script>
<template>
<div id="page-connexion">
<SectionAuthConnexion />
</div>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -2,3 +2,6 @@
// Core // Core
require_once __DIR__ . '/includes/core/theme-setup.php'; require_once __DIR__ . '/includes/core/theme-setup.php';
// Vendors
require_once __DIR__ . '/includes/vendors/tinymce.php';

View File

@@ -0,0 +1,129 @@
<?php
// Enable formats (styleselect) in TinyMCE
add_filter( 'mce_buttons', 'moonshine_tinymce_styleselect' );
function moonshine_tinymce_styleselect( $buttons ) {
array_unshift( $buttons, 'styleselect' );
return $buttons;
}
// Configure TinyMCE
add_filter( 'tiny_mce_before_init', 'moonshine_tiny_mce_before_init' );
function moonshine_tiny_mce_before_init( $settings ) {
// Reset TinyMCE editor CSS
if ( isset( $settings['content_css'] ) ) {
$content_css = explode( ',', $settings['content_css'] );
unset( $content_css[1] ); // wp-content.min.css
$settings['content_css'] = implode( ',', $content_css );
}
// Format styles
$settings['style_formats'] = wp_json_encode(
array(
array(
'title' => __( "Link styles", 'moonshine' ),
'items' => array(// Link styles
array(
'title' => "Lien (opacité)",
'selector' => 'a',
'classes' => 'link-opacity',
),
),
),
array(
'title' => __( "Inline styles", 'moonshine' ),
'items' => array(// Inline styles
array(
'title' => __( "Semi-bold", 'moonshine' ),
'inline' => 'span',
'classes' => 'font-semibold',
),
),
),
array(
'title' => __( "Paragraph styles", 'moonshine' ),
'items' => array(// Paragraph styles
array(
'title' => "Paragraphe vedette",
'block' => 'p',
'classes' => 'paragraph-lead',
),
),
),
array(
'title' => __( "Heading styles", 'moonshine' ),
'items' => array(// Heading styles
array(
'title' => "Titre 1",
'selector' => 'h1,h2,h3,h4',
'classes' => 'heading-1',
),
array(
'title' => "Titre 2",
'selector' => 'h1,h2,h3,h4',
'classes' => 'heading-2',
),
array(
'title' => "Titre 3",
'selector' => 'h1,h2,h3,h4',
'classes' => 'heading-3',
),
array(
'title' => "Titre 4",
'selector' => 'h1,h2,h3,h4',
'classes' => 'heading-4',
),
),
),
)
);
// Block styles
$settings['block_formats'] = implode(
';',
array(
'Paragraph=p',
'Heading 1=h1',
'Heading 2=h2',
'Heading 3=h3',
'Heading 4=h4',
)
);
return $settings;
}
// Override TinyMCE editor styles
add_filter( 'mce_css', 'moonshine_override_editor_styles' );
function moonshine_override_editor_styles() {
return get_stylesheet_directory_uri() . '/editor-style.css';
}
// Remove default TinyMCE styles for all editors (WordPress & ACF)
add_action( 'admin_print_footer_scripts', 'moonshine_remove_tinymce_default_styles', 99 );
function moonshine_remove_tinymce_default_styles() {
?>
<script>
(function($) {
if (typeof tinymce !== 'undefined') {
tinymce.on('AddEditor', function({editor}) {
editor.on('init', function() {
$(editor.iframeElement).contents().find("link[href*='content.min.css']").remove();
});
});
}
})(jQuery);
</script>
<?php
}
// Convert absolute URLs to relative in link query
add_filter( 'wp_link_query', 'moonshine_tinymce_relative_urls' );
function moonshine_tinymce_relative_urls( $results ) {
foreach ( $results as &$result ) {
if ( empty( $result['permalink'] ) ) {
continue;
}
$result['permalink'] = str_replace( get_home_url(), '', $result['permalink'] );
}
return $results;
}

View File

@@ -5,6 +5,8 @@ export default defineNuxtConfig({
"@lewebsimple/nuxt-graphql", "@lewebsimple/nuxt-graphql",
"@nuxt/eslint", "@nuxt/eslint",
"@nuxt/ui", "@nuxt/ui",
"@nuxtjs/seo",
"nuxt-auth-utils",
], ],
components: { components: {
@@ -17,6 +19,12 @@ export default defineNuxtConfig({
css: ["~/assets/css/_main.css"], css: ["~/assets/css/_main.css"],
site: {
url: "https://wp-headless.ledevsimple.ca",
name: "WP Headless",
defaultLocale: "fr",
},
ui: { ui: {
colorMode: false, colorMode: false,
}, },
@@ -36,12 +44,20 @@ export default defineNuxtConfig({
}, },
graphql: { graphql: {
schemas: { yoga: {
wp: { context: ["~~/server/graphql/context"],
type: "remote", schemas: {
url: `${process.env.NUXT_WP_URL || "https://wp-headless.ledevsimple.ca"}/graphql`, wp: {
type: "remote",
url: `${process.env.NUXT_WP_URL || "https://wp-headless.ledevsimple.ca"}/graphql`,
hooks: ["~~/server/graphql/wp-hooks"],
},
}, },
}, },
saveSdl: "server/graphql/schema.graphql",
}, },
sitemap: {
zeroRuntime: true,
},
}); });

View File

@@ -1,27 +1,32 @@
{ {
"name": "@lewebsimple/moonshine", "name": "@lewebsimple/moonshine",
"description": "Headless WordPress theme based on Nuxt.", "description": "Headless WordPress theme based on Nuxt.",
"version": "0.1.1", "version": "0.1.5",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev --host 0.0.0.0", "editor-style": "pnpx @tailwindcss/cli -i ./app/assets/css/_main.css -o ./editor-style.css --minify",
"dev": "nuxt dev",
"lint": "eslint --fix .", "lint": "eslint --fix .",
"postinstall": "pnpm --sequential /postinstall:.*/", "postinstall": "pnpm --sequential /postinstall:.*/",
"postinstall:nuxt": "nuxt prepare", "postinstall:nuxt": "nuxt prepare",
"preview": "nuxt preview", "preview": "nuxt preview",
"release": "pnpm lint && pnpm typecheck && changelogen --noAuthors --release --push", "release": "pnpm lint && changelogen --noAuthors --release --push",
"typecheck": "nuxt typecheck" "typecheck": "nuxt typecheck"
}, },
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.84", "@iconify-json/lucide": "^1.2.86",
"@lewebsimple/nuxt-graphql": "^0.3.5", "@lewebsimple/nuxt-graphql": "^0.5.10",
"@nuxt/ui": "4.3.0", "@nuxt/ui": "4.3.0",
"@nuxtjs/seo": "^3.3.0",
"jwt-decode": "^4.0.0",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"nuxt-auth-utils": "^0.5.27",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vue": "^3.5.26", "vue": "^3.5.27",
"vue-router": "^4.6.4" "vue-router": "^4.6.4",
"zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "^1.12.1", "@nuxt/eslint": "^1.12.1",
@@ -30,6 +35,12 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vue-tsc": "^3.2.2" "vue-tsc": "^3.2.2"
}, },
"pnpm": {
"overrides": {
"@tiptap/core": "3.14.0",
"@tiptap/pm": "3.14.0"
}
},
"changelog": { "changelog": {
"types": { "types": {
"chore": false "chore": false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- esbuild
- unrs-resolver
- vue-demi

View File

@@ -1,2 +0,0 @@
User-Agent: *
Disallow:

View File

@@ -0,0 +1,21 @@
export default defineEventHandler(async (event) => {
try {
const variables = await readBody<AuthLoginForm>(event);
const { data } = await useServerGraphQLMutation(event, "AuthLogin", variables);
if (!data?.login) {
throw new Error("INVALID_LOGIN");
}
if (!await handleLogin(event, data)) {
throw new Error("LOGIN_FAILED");
}
return { success: true, message: "Connexion réussie" };
}
catch (error) {
const messages = {
INVALID_LOGIN: "Identifiants invalides. Veuillez réessayer.",
LOGIN_FAILED: "Une erreur est survenue lors de la connexion. Veuillez réessayer plus tard.",
};
const message = (error instanceof Error && error.message in messages) ? error.message : "LOGIN_FAILED";
return { success: false, message: messages[message as keyof typeof messages] };
}
});

View File

@@ -0,0 +1,12 @@
import { defineEventHandler } from "h3";
export default defineEventHandler(async (event) => {
try {
await handleLogout(event);
return { success: true, message: "Déconnexion réussie" };
}
catch (error) {
const message = error instanceof Error ? error.message : "Une erreur est survenue lors de la déconnexion.";
return { success: false, message };
}
});

View File

@@ -0,0 +1,6 @@
export default defineGraphQLContext(async (event) => {
const authToken = await getAuthToken(event);
return {
authToken,
};
});

View File

@@ -39,6 +39,18 @@ type ACFE_AdvancedLink_Url implements ACFE_AdvancedLink {
url: String url: String
} }
"""A Field Group managed by ACF"""
interface AcfFieldGroup {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
}
"""Fields associated with an ACF Field Group"""
interface AcfFieldGroupFields {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
}
"""The Headless Login authentication data.""" """The Headless Login authentication data."""
type AuthenticationData { type AuthenticationData {
"""A new authentication token to use in future requests.""" """A new authentication token to use in future requests."""
@@ -3295,6 +3307,179 @@ enum GoogleProviderPromptTypeEnum {
SELECT_ACCOUNT SELECT_ACCOUNT
} }
"""
The &quot;GroupAbstractBuilder&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
"""
type GroupAbstractBuilder implements AcfFieldGroup & AcfFieldGroupFields & GroupAbstractBuilder_Fields {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;flexible_content&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilder&quot; Field Group
"""
sections: [GroupAbstractBuilderSections_Layout]
}
"""
The &quot;GroupAbstractBuilderSectionsLayoutSettings&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
"""
type GroupAbstractBuilderSectionsLayoutSettings implements AcfFieldGroup & AcfFieldGroupFields & GroupAbstractBuilderSectionsLayoutSettings_Fields & GroupLayoutContained_Fields {
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
bgColor: [String]!
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
container: [String]!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
verticalPadding: [String]!
}
"""
Interface representing fields of the ACF &quot;GroupAbstractBuilderSectionsLayoutSettings&quot; Field Group
"""
interface GroupAbstractBuilderSectionsLayoutSettings_Fields implements AcfFieldGroup & AcfFieldGroupFields {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
}
"""
The &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
"""
type GroupAbstractBuilderSectionsTextBlockLayout implements AcfFieldGroup & AcfFieldGroupFields & GroupAbstractBuilderSectionsTextBlockLayout_Fields & GroupAbstractBuilderSections_Layout {
"""
Field of the &quot;wysiwyg&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group
"""
content: String!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;clone&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group
"""
layoutSettings: GroupAbstractBuilderSectionsLayoutSettings
}
"""
Interface representing fields of the ACF &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group
"""
interface GroupAbstractBuilderSectionsTextBlockLayout_Fields implements AcfFieldGroup & AcfFieldGroupFields & GroupAbstractBuilderSections_Layout {
"""
Field of the &quot;wysiwyg&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group
"""
content: String!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;clone&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group
"""
layoutSettings: GroupAbstractBuilderSectionsLayoutSettings
}
"""
Layout of the &quot;sections&quot; Field of the &quot;GroupAbstractBuilder&quot; Field Group Field
"""
interface GroupAbstractBuilderSections_Layout {
"""The name of the ACF Flex Field Layout"""
fieldGroupName: String
}
"""
Interface representing fields of the ACF &quot;GroupAbstractBuilder&quot; Field Group
"""
interface GroupAbstractBuilder_Fields implements AcfFieldGroup & AcfFieldGroupFields {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;flexible_content&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilder&quot; Field Group
"""
sections: [GroupAbstractBuilderSections_Layout]
}
"""
The &quot;GroupLayoutContained&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
"""
type GroupLayoutContained implements AcfFieldGroup & AcfFieldGroupFields & GroupLayoutContained_Fields {
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
bgColor: [String]!
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
container: [String]!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
verticalPadding: [String]!
}
"""
Interface representing fields of the ACF &quot;GroupLayoutContained&quot; Field Group
"""
interface GroupLayoutContained_Fields implements AcfFieldGroup & AcfFieldGroupFields {
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
bgColor: [String]!
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
container: [String]!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;select&quot; Field Type added to the schema as part of the &quot;GroupLayoutContained&quot; Field Group
"""
verticalPadding: [String]!
}
"""
The &quot;GroupPostPage&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
"""
type GroupPostPage implements AcfFieldGroup & AcfFieldGroupFields & GroupAbstractBuilder_Fields & GroupPostPage_Fields {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;flexible_content&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilder&quot; Field Group
"""
sections: [GroupAbstractBuilderSections_Layout]
}
"""
Interface representing fields of the ACF &quot;GroupPostPage&quot; Field Group
"""
interface GroupPostPage_Fields implements AcfFieldGroup & AcfFieldGroupFields & GroupAbstractBuilder_Fields {
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
"""
Field of the &quot;flexible_content&quot; Field Type added to the schema as part of the &quot;GroupAbstractBuilder&quot; Field Group
"""
sections: [GroupAbstractBuilderSections_Layout]
}
""" """
Content that can be organized in a parent-child structure. Provides fields for navigating up and down the hierarchy and maintaining structured relationships. Content that can be organized in a parent-child structure. Provides fields for navigating up and down the hierarchy and maintaining structured relationships.
""" """
@@ -5759,7 +5944,7 @@ enum OrderEnum {
""" """
A standalone content entry generally used for static, non-chronological content such as &quot;About Us&quot; or &quot;Contact&quot; pages. A standalone content entry generally used for static, non-chronological content such as &quot;About Us&quot; or &quot;Contact&quot; pages.
""" """
type Page implements ContentNode & DatabaseIdentifier & HierarchicalContentNode & HierarchicalNode & MenuItemLinkable & Node & NodeWithAuthor & NodeWithContentEditor & NodeWithFeaturedImage & NodeWithPageAttributes & NodeWithRevisions & NodeWithTemplate & NodeWithTitle & Previewable & UniformResourceIdentifiable { type Page implements ContentNode & DatabaseIdentifier & HierarchicalContentNode & HierarchicalNode & MenuItemLinkable & Node & NodeWithAuthor & NodeWithContentEditor & NodeWithFeaturedImage & NodeWithPageAttributes & NodeWithRevisions & NodeWithTemplate & NodeWithTitle & Previewable & UniformResourceIdentifiable & WithAcfGroupPostPage {
""" """
Returns ancestors of the node. Default ordered as lowest (closest to the child) to highest (closest to the root). Returns ancestors of the node. Default ordered as lowest (closest to the child) to highest (closest to the root).
""" """
@@ -5902,6 +6087,9 @@ type Page implements ContentNode & DatabaseIdentifier & HierarchicalContentNode
"""Globally unique ID of the featured image assigned to the node""" """Globally unique ID of the featured image assigned to the node"""
featuredImageId: ID featuredImageId: ID
"""Fields of the GroupPostPage ACF Field Group"""
groupPostPage: GroupPostPage
""" """
The global unique identifier for this post. This currently matches the value stored in WP_Post-&gt;guid and the guid column in the &quot;post_objects&quot; database table. The global unique identifier for this post. This currently matches the value stored in WP_Post-&gt;guid and the guid column in the &quot;post_objects&quot; database table.
""" """
@@ -13884,6 +14072,14 @@ interface WPPageInfo implements PageInfo {
startCursor: String startCursor: String
} }
"""
Provides access to fields of the &quot;GroupPostPage&quot; ACF Field Group via the &quot;groupPostPage&quot; field
"""
interface WithAcfGroupPostPage {
"""Fields of the GroupPostPage ACF Field Group"""
groupPostPage: GroupPostPage
}
"""The writing setting type""" """The writing setting type"""
type WritingSettings { type WritingSettings {
"""Catégorie darticle par défaut.""" """Catégorie darticle par défaut."""

View File

@@ -0,0 +1,11 @@
export default defineRemoteExecutorHooks({
onRequest(request) {
if (request.context.authToken) {
request.extensions ??= {};
request.extensions.headers = {
...request.extensions.headers,
Authorization: `Bearer ${request.context.authToken}`,
};
}
},
});

View File

@@ -0,0 +1,78 @@
import type { H3Event } from "h3";
import { GraphQLClient } from "graphql-request";
import { jwtDecode } from "jwt-decode";
import type { User } from "#auth-utils";
import type { AuthUserFragment, AuthLoginMutationResult } from "#graphql/operations";
import { AuthRefreshTokenDocument } from "#graphql/operations";
// Handle login result and store user session
export async function handleLogin(event: H3Event, loginResult: AuthLoginMutationResult) {
if (!loginResult?.login) {
return false;
}
const { user, authToken, refreshToken } = loginResult.login;
if (!user || !authToken || !refreshToken) {
return false;
}
await setUserSession(event, {
user: getAuthUser(user),
secure: {
authToken,
refreshToken,
},
loggedInAt: new Date().toISOString(),
});
return true;
}
// Handle user logout by clearing session
export async function handleLogout(event: H3Event) {
await clearUserSession(event);
return true;
}
// Convert AuthUserFragment to nuxt-auth-utils User
function getAuthUser(user: AuthUserFragment): User {
return {
id: Number(user.id),
email: user.email!,
roles: extractNodes(user.roles).map(({ name }) => name!) || [],
};
}
// Refresh auth token by calling remote GraphQL endpoint directly
export async function refreshAuthToken(refreshToken: string): Promise<string | undefined> {
const client = new GraphQLClient(`${process.env.NUXT_WP_URL || "https://wp-headless.ledevsimple.ca"}/graphql`);
const data = await client.request(AuthRefreshTokenDocument, { refreshToken });
return data.refreshToken?.authToken || undefined;
}
// Get auth token from user session (refresh if needed)
export async function getAuthToken(event: H3Event): Promise<string | undefined> {
// Retrieve user session, return if none
const session = await getUserSession(event);
if (!session.secure) {
return;
}
// Extract tokens and check expiration
const { authToken, refreshToken } = session.secure;
const decoded = jwtDecode<{ exp: number }>(authToken);
const isExpired = decoded.exp * 1000 < Date.now();
if (isExpired) {
try {
const newAuthToken = await refreshAuthToken(refreshToken);
if (!newAuthToken) {
throw new Error("Impossible de rafraîchir le jeton d'authentification.");
}
session.secure.authToken = newAuthToken;
await setUserSession(event, session);
}
catch {
await clearUserSession(event);
return;
}
}
return session.secure.authToken;
}

View File

@@ -0,0 +1,19 @@
// auth.d.ts
declare module "#auth-utils" {
interface User {
id: number;
email: string;
roles: string[];
}
interface UserSession {
loggedInAt: string;
}
interface SecureSessionData {
authToken: string;
refreshToken: string;
}
}
export { };

View File

@@ -0,0 +1,8 @@
import z from "zod";
export const authLoginFormSchema = z.object({
username: z.email("Courriel invalide"),
password: z.string("Veuillez saisir votre mot de passe"),
});
export type AuthLoginForm = z.infer<typeof authLoginFormSchema>;

View File

@@ -0,0 +1,3 @@
export async function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,4 @@
// Helper: Extracts nodes from a GraphQL connection object, returning an empty array if nodes are absent.
export function extractNodes<T>(connection: { nodes?: readonly T[] } | null | undefined): readonly T[] {
return connection?.nodes ?? [];
}