23 Commits

Author SHA1 Message Date
4492d760bb minor: OptionsSite.query.gql 2026-01-22 08:10:17 -05:00
489ac82faa feat: Site options page & field group 2026-01-22 08:07:10 -05:00
d7cf08db00 chore: README.md 2026-01-22 07:56:51 -05:00
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
49 changed files with 2938 additions and 2088 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,65 @@
# 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 ## v0.1.2
[compare changes](https://gitea.websimple.com/templates/wp-headless/compare/v0.1.1...v0.1.2) [compare changes](https://gitea.websimple.com/templates/wp-headless/compare/v0.1.1...v0.1.2)

View File

@@ -1,3 +1,12 @@
# Moonshine # Moonshine
Headless WordPress theme based on Nuxt. Thème WordPress en headless basé sur Nuxt.
## Varaibles d'environnement
| Nom | Description | Exemple | Requise |
|-----|-------------|---------|---------|
| `NUXT_SESSION_PASSWORD` | Clé secrète pour l'authentification | `date \| md5sum` | ✅ |
| `NUXT_WP_URL` | URL du backend WordPress | https://wp.exemple.com | ✅ |
| `NUXT_SITE_URL` | URL du frontend Nuxt | https://www.example.com | |
| `NUXT_SITE_ENV` | Environnement | staging \| production | |

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,60 @@
{
"key": "group_options_site",
"title": "Options - Site",
"fields": [
{
"key": "field_697220310aaaf",
"label": "Email",
"name": "email",
"aria-label": "",
"type": "email",
"instructions": "",
"required": 1,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": "",
"show_in_graphql": 1,
"graphql_description": "",
"graphql_field_name": "email",
"graphql_non_null": 1
}
],
"location": [
[
{
"param": "options_page",
"operator": "==",
"value": "site-options"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "seamless",
"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": "GroupSite",
"map_graphql_types_from_location_rules": 0,
"graphql_types": "",
"acfe_meta": "",
"acfe_note": "",
"modified": 1769087407
}

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,25 @@
{
"key": "ui_options_page_site",
"title": "Options du site",
"active": true,
"menu_order": 0,
"page_title": "Options du site",
"menu_slug": "site-options",
"parent_slug": "options-general.php",
"advanced_configuration": 1,
"icon_url": "",
"menu_title": "",
"position": "",
"redirect": false,
"description": "",
"menu_icon": [],
"update_button": "Mise à jour",
"updated_message": "Options mises à jours",
"capability": "edit_posts",
"data_storage": "options",
"post_id": "",
"autoload": 0,
"show_in_graphql": 1,
"graphql_type_name": "OptionsSite",
"modified": 1769086997
}

View File

@@ -1,4 +1,10 @@
@import "tailwindcss"; @import "tailwindcss" theme(static) source("../../..");
@import "@nuxt/ui"; @import "@nuxt/ui";
@import "./a11y.css";
@import "./containers.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,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,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,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

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

@@ -1,16 +1,9 @@
import z from "zod";
import type { FormSubmitEvent } from "@nuxt/ui"; import type { FormSubmitEvent } from "@nuxt/ui";
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>;
const isRedirecting = ref(false); const isRedirecting = ref(false);
export function useAuthConnexion() { export function useAuthConnexion() {
const toast = useToast();
const { fetch: refreshUserSession } = useUserSession(); const { fetch: refreshUserSession } = useUserSession();
const routeRedirect = useRoute().query.redirect as string || undefined; const routeRedirect = useRoute().query.redirect as string || undefined;
@@ -20,21 +13,31 @@ export function useAuthConnexion() {
await delay(1000); await delay(1000);
await refreshUserSession(); await refreshUserSession();
await navigateTo(to || routeRedirect || "/"); await navigateTo(to || routeRedirect || "/");
isRedirecting.value = false;
} }
// Login // Login
const { mutate: loginMutate } = useGraphQLMutation("AuthLogin"); async function login({ data: body }: FormSubmitEvent<AuthLoginForm>, redirect?: string) {
async function login({ data: variables }: FormSubmitEvent<AuthLoginForm>, redirect?: string) {
try { try {
const { data } = await loginMutate(variables); const { success, message } = await $fetch("/api/login", { method: "POST", body });
if (!data.login) { if (!success) {
throw new Error(`Échec de la connexion par mot de passe.`); throw new Error(message);
} }
toast.add({
title: "Connexion réussie",
color: "success",
description: message,
duration: 3000,
});
await redirectTo(redirect); await redirectTo(redirect);
} }
catch (error) { catch (error) {
console.log(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,
});
} }
} }
@@ -45,10 +48,22 @@ export function useAuthConnexion() {
if (!result.success) { if (!result.success) {
throw new Error("Échec de la déconnexion."); 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); await redirectTo(redirect);
} }
catch (error) { catch (error) {
console.log(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,
});
} }
} }

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

@@ -13,7 +13,7 @@ login( input: { provider: PASSWORD, credentials: { username: $username, password
authToken authToken
refreshToken refreshToken
user { user {
...AuthUser ... AuthUser
} }
} }
} }

View File

@@ -1,6 +1,10 @@
query GeneralSettings { fragment GeneralSettings on GeneralSettings {
generalSettings {
title title
description description
}
query GeneralSettings {
generalSettings {
... GeneralSettings
} }
} }

View File

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

View File

@@ -0,0 +1,11 @@
fragment SiteOptions on GroupSite_Fields {
email
}
query OptionsSite {
optionsSite {
groupSite {
... SiteOptions
}
}
}

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

@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
const { isRedirecting } = useAuthConnexion();
onBeforeMount(() => {
isRedirecting.value = false;
});
</script> </script>
<template> <template>

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,15 @@
<?php
// Disable ACF / ACFE modules
add_filter( 'acf/settings/enable_post_types', '__return_false' );
add_action( 'acf/init', 'moonshine_acf_init' );
function moonshine_acf_init() {
acf_update_setting( 'acfe/modules/block_types', false );
acf_update_setting( 'acfe/modules/categories', false );
acf_update_setting( 'acfe/modules/forms', false );
acf_update_setting( 'acfe/modules/options', false );
acf_update_setting( 'acfe/modules/options_pages', false );
acf_update_setting( 'acfe/modules/post_types', false );
acf_update_setting( 'acfe/modules/taxonomies', false );
acf_update_setting( 'acfe/modules/templates', false );
}

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,7 @@ export default defineNuxtConfig({
"@lewebsimple/nuxt-graphql", "@lewebsimple/nuxt-graphql",
"@nuxt/eslint", "@nuxt/eslint",
"@nuxt/ui", "@nuxt/ui",
"@nuxtjs/seo",
"nuxt-auth-utils", "nuxt-auth-utils",
], ],
@@ -18,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,
}, },
@@ -37,14 +44,20 @@ export default defineNuxtConfig({
}, },
graphql: { graphql: {
context: "server/graphql/context.ts", yoga: {
context: ["~~/server/graphql/context"],
schemas: { schemas: {
wp: { wp: {
type: "remote", type: "remote",
url: `${process.env.NUXT_WP_URL || "https://wp-headless.ledevsimple.ca"}/graphql`, url: `${process.env.NUXT_WP_URL || "https://wp-headless.ledevsimple.ca"}/graphql`,
middleware: "server/graphql/wp-middleware.ts", hooks: ["~~/server/graphql/wp-hooks"],
}, },
}, },
saveSdl: "server/graphql/schema.graphql",
}, },
},
sitemap: {
zeroRuntime: true,
},
}); });

View File

@@ -1,28 +1,30 @@
{ {
"name": "@lewebsimple/moonshine", "name": "@lewebsimple/moonshine",
"description": "Headless WordPress theme based on Nuxt.", "description": "Headless WordPress theme based on Nuxt.",
"version": "0.1.2", "version": "0.1.5",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"editor-style": "pnpx @tailwindcss/cli -i ./app/assets/css/_main.css -o ./editor-style.css --minify",
"dev": "nuxt dev", "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.4.0", "@lewebsimple/nuxt-graphql": "^0.5.11",
"@nuxt/ui": "4.3.0", "@nuxt/ui": "4.3.0",
"@nuxtjs/seo": "^3.3.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"nuxt-auth-utils": "^0.5.27", "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" "zod": "^4.3.5"
}, },

File diff suppressed because it is too large Load Diff

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

@@ -1,11 +1,6 @@
import { defineGraphQLContext } from "@lewebsimple/nuxt-graphql/helpers";
import type { AuthLoginMutation } from "#graphql/typed-documents";
export default defineGraphQLContext(async (event) => { export default defineGraphQLContext(async (event) => {
const authToken = await getAuthToken(event); const authToken = await getAuthToken(event);
return { return {
authToken, authToken,
handleLogin: async (loginData: AuthLoginMutation) => handleLogin(event, loginData),
}; };
}); });

View File

@@ -51,6 +51,21 @@ interface AcfFieldGroupFields {
fieldGroupName: String @deprecated(reason: "Use __typename instead") fieldGroupName: String @deprecated(reason: "Use __typename instead")
} }
"""Options Page registered by ACF"""
interface AcfOptionsPage implements Node {
"""The globally unique ID for the object"""
id: ID!
""""""
menuTitle: String
""""""
pageTitle: String
""""""
parentId: String
}
"""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."""
@@ -3320,6 +3335,37 @@ type GroupAbstractBuilder implements AcfFieldGroup & AcfFieldGroupFields & Group
sections: [GroupAbstractBuilderSections_Layout] 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;. The &quot;GroupAbstractBuilderSectionsTextBlockLayout&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
""" """
@@ -3331,6 +3377,11 @@ type GroupAbstractBuilderSectionsTextBlockLayout implements AcfFieldGroup & AcfF
"""The name of the field group""" """The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead") 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
} }
""" """
@@ -3344,6 +3395,11 @@ interface GroupAbstractBuilderSectionsTextBlockLayout_Fields implements AcfField
"""The name of the field group""" """The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead") 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
} }
""" """
@@ -3367,6 +3423,52 @@ interface GroupAbstractBuilder_Fields implements AcfFieldGroup & AcfFieldGroupFi
sections: [GroupAbstractBuilderSections_Layout] 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;. The &quot;GroupPostPage&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
""" """
@@ -3393,6 +3495,32 @@ interface GroupPostPage_Fields implements AcfFieldGroup & AcfFieldGroupFields &
sections: [GroupAbstractBuilderSections_Layout] sections: [GroupAbstractBuilderSections_Layout]
} }
"""
The &quot;GroupSite&quot; Field Group. Added to the Schema by &quot;WPGraphQL for ACF&quot;.
"""
type GroupSite implements AcfFieldGroup & AcfFieldGroupFields & GroupSite_Fields {
"""
Field of the &quot;email&quot; Field Type added to the schema as part of the &quot;GroupSite&quot; Field Group
"""
email: String!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
}
"""
Interface representing fields of the ACF &quot;GroupSite&quot; Field Group
"""
interface GroupSite_Fields implements AcfFieldGroup & AcfFieldGroupFields {
"""
Field of the &quot;email&quot; Field Type added to the schema as part of the &quot;GroupSite&quot; Field Group
"""
email: String!
"""The name of the field group"""
fieldGroupName: String @deprecated(reason: "Use __typename instead")
}
""" """
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.
""" """
@@ -5839,6 +5967,23 @@ interface OneToOneConnection implements Edge {
node: Node! node: Node!
} }
type OptionsSite implements AcfOptionsPage & Node & WithAcfGroupSite {
"""Fields of the GroupSite ACF Field Group"""
groupSite: GroupSite
"""The globally unique ID for the object"""
id: ID!
""""""
menuTitle: String
""""""
pageTitle: String
""""""
parentId: String
}
""" """
Sort direction for ordered results. Determines whether items are returned in ascending or descending order. Sort direction for ordered results. Determines whether items are returned in ascending or descending order.
""" """
@@ -8349,7 +8494,7 @@ interface Previewable {
} }
"""The root entry point into the Graph""" """The root entry point into the Graph"""
type Query { type Query implements WithAcfOptionsPageOptionsSite {
"""Entry point to get all settings for the site""" """Entry point to get all settings for the site"""
allSettings: Settings allSettings: Settings
@@ -8634,6 +8779,9 @@ type Query {
uri: String! uri: String!
): UniformResourceIdentifiable ): UniformResourceIdentifiable
""""""
optionsSite: OptionsSite
"""An object of the page Type. """ """An object of the page Type. """
page( page(
""" """
@@ -13993,6 +14141,20 @@ interface WithAcfGroupPostPage {
groupPostPage: GroupPostPage groupPostPage: GroupPostPage
} }
"""
Provides access to fields of the &quot;GroupSite&quot; ACF Field Group via the &quot;groupSite&quot; field
"""
interface WithAcfGroupSite {
"""Fields of the GroupSite ACF Field Group"""
groupSite: GroupSite
}
"""Access point for the &quot;OptionsSite&quot; ACF Options Page"""
interface WithAcfOptionsPageOptionsSite {
""""""
optionsSite: OptionsSite
}
"""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

@@ -1,20 +0,0 @@
import type { AuthLoginMutation } from "#build/graphql/typed-documents";
import { defineRemoteExecMiddleware } from "@lewebsimple/nuxt-graphql/helpers";
export default defineRemoteExecMiddleware({
onRequest({ context, fetchOptions }) {
// Attach auth token from context to request headers
if (context.authToken) {
fetchOptions.headers.set("Authorization", `Bearer ${context.authToken}`);
}
},
async onResponse({ operationName, response, context }) {
// Save auth token in user session
if (operationName === "AuthLogin") {
const { data } = await response.json() as { data?: AuthLoginMutation };
if (data) {
await context.handleLogin(data);
}
}
},
});

View File

@@ -2,16 +2,17 @@ import type { H3Event } from "h3";
import { GraphQLClient } from "graphql-request"; import { GraphQLClient } from "graphql-request";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import type { User } from "#auth-utils"; import type { User } from "#auth-utils";
import { type AuthUserFragment, type AuthLoginMutation, AuthRefreshTokenDocument } from "#graphql/typed-documents"; import type { AuthUserFragment, AuthLoginMutationResult } from "#graphql/operations";
import { AuthRefreshTokenDocument } from "#graphql/operations";
// Handle login result and store user session // Handle login result and store user session
export async function handleLogin(event: H3Event, loginData: AuthLoginMutation) { export async function handleLogin(event: H3Event, loginResult: AuthLoginMutationResult) {
if (!loginData?.login) { if (!loginResult?.login) {
return; return false;
} }
const { user, authToken, refreshToken } = loginData.login; const { user, authToken, refreshToken } = loginResult.login;
if (!user || !authToken || !refreshToken) { if (!user || !authToken || !refreshToken) {
return; return false;
} }
await setUserSession(event, { await setUserSession(event, {
user: getAuthUser(user), user: getAuthUser(user),
@@ -21,11 +22,13 @@ export async function handleLogin(event: H3Event, loginData: AuthLoginMutation)
}, },
loggedInAt: new Date().toISOString(), loggedInAt: new Date().toISOString(),
}); });
return true;
} }
// Handle user logout by clearing session // Handle user logout by clearing session
export async function handleLogout(event: H3Event) { export async function handleLogout(event: H3Event) {
await clearUserSession(event); await clearUserSession(event);
return true;
} }
// Convert AuthUserFragment to nuxt-auth-utils User // Convert AuthUserFragment to nuxt-auth-utils User

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

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