├── .github
├── ISSUE_TEMPLATE
│ └── component-request.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── .storybook
├── fonts.css
├── main.js
└── preview.js
├── .vscode
└── settings.json
├── README.md
├── TODO-FUXT.md
├── app
├── app.vue
├── assets
│ ├── css
│ │ ├── main.css
│ │ ├── media.css
│ │ ├── transitions.css
│ │ └── vars.css
│ └── svgs
│ │ └── logo-funkhaus.svg
├── components
│ ├── global-hamburger.vue
│ ├── global-header.vue
│ ├── wp-block
│ │ ├── core-column.vue
│ │ ├── core-columns.vue
│ │ ├── core-embed.vue
│ │ ├── core-gallery.vue
│ │ ├── core-heading.vue
│ │ ├── core-image.vue
│ │ ├── core-list-item.vue
│ │ ├── core-list.vue
│ │ ├── core-paragraph.vue
│ │ ├── core-quote.vue
│ │ ├── core-spacer.vue
│ │ └── core-video.vue
│ ├── wp-content.vue
│ ├── wp-image.vue
│ ├── wp-menu-item.vue
│ ├── wp-menu.vue
│ └── wp-seo.vue
├── composables
│ └── useWpFetch.ts
├── error.vue
├── layouts
│ └── default.vue
├── middleware
│ └── redirect-trailing-slash.global.ts
├── pages
│ ├── [slug].vue
│ └── index.vue
├── plugins
│ ├── browser.client.ts
│ └── init.ts
├── stores
│ └── site.ts
├── types
│ ├── global-hamburger.d.ts
│ └── index.d.ts
└── utils
│ ├── buildVideoEmbedUrl.ts
│ ├── decodeHtmlEntities.ts
│ ├── delay.ts
│ ├── emdashed.ts
│ ├── getCookie.ts
│ ├── keysToCamelCase.ts
│ ├── setCookie.ts
│ └── splitAtDash.ts
├── eslint.config.mjs
├── example.env
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── public
└── favicon.png
├── stories
└── global-header
│ ├── Docs.mdx
│ └── global-header.stories.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/component-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Component Request
3 | about: Use this to define a component that a developer can build.
4 | title: Component Request - {ComponentName} - {x}hrs
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | ## Component Description
10 |
11 | This component is used {where} to display {what}. Be sure to explain any variants or hover states.
12 |
13 | ## Design
14 |
15 | Please also see attached screenshots for quick reference.
16 |
17 | - Desktop: https://xd.adobe.com/view/1234-5678/
18 | - Mobile: https://xd.adobe.com/view/1234-5678/
19 |
20 | If no mobile designs provided, please use your best judgment for responsiveness.
21 |
22 | ## Slots
23 |
24 | Name and description of any slots needed.
25 |
26 | ## Props
27 |
28 | ```
29 | props: {
30 | exampleObject: {
31 | // Mock: api.page
32 | type: Object,
33 | default: () => ({})
34 | },
35 | exampleArray: {
36 | // Mock: api.pages
37 | type: Array,
38 | default: () => []
39 | },
40 | exampleNumber: {
41 | type: Number,
42 | default: 0
43 | },
44 | exampleString: {
45 | type: String,
46 | default: ""
47 | },
48 | exampleBoolean: {
49 | type: Boolean,
50 | default: true
51 | }
52 | }
53 | ```
54 |
55 | ## Developer Tips
56 |
57 | List any developer tips here
58 |
59 | 1. `--color-example` for the font color
60 |
61 | ## Events
62 |
63 | Describe any events that should be emitted by this component.
64 |
65 | 1. `menuOpened` when {something} is clicked on
66 |
67 | ## Required components
68 |
69 | List out any components that are used by this new component. For example, if you are building a grid that is made up of block components.
70 |
71 | 1. `required-component` is used for {what}
72 |
73 | ## Screenshots
74 |
75 | {attach screenshots}
76 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | **Component Created:** {path and filename}.vue from #{issue number}
2 |
3 | **Stories:** {story filename}.stories.ts
4 |
5 | **Imports:**
6 |
7 | - {list any imported components or libraries}
8 |
9 | **Notes:**
10 |
11 | {Any notes about what you built. How does it work? Anything missing?}
12 |
13 | **Time Report:**
14 |
15 | This took me {x} hours to build this.
16 |
17 | **Checklist:**
18 |
19 | - [ ] I double checked it looks like the designs
20 | - [ ] I completed any required mobile breakpoint styling
21 | - [ ] I completed any required hover state styling
22 | - [ ] I included a working Storybook file
23 | - [ ] I included a Story that showed some edge case working correctly (long text, short screen, missing image etc.)
24 | - [ ] I added notes above about how long it took to build this component
25 | - [ ] I assigned this PR to someone to review
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Nuxt dev/build outputs
2 | .output
3 | .data
4 | .nuxt
5 | .nitro
6 | .cache
7 | dist
8 |
9 | # Node dependencies
10 | node_modules
11 |
12 | # Logs
13 | logs
14 | *.log
15 |
16 | # Misc
17 | .DS_Store
18 | .fleet
19 | .idea
20 |
21 | # Local env files
22 | .env
23 | .env.*
24 | !.env.example
25 |
--------------------------------------------------------------------------------
/.storybook/fonts.css:
--------------------------------------------------------------------------------
1 | /*
2 | Load the fonts in this file so that they are available in Storybook.
3 |
4 | Load fonts in this file using @font-face.
5 | Fonts should go in: ~/public/fonts/
6 | Example:
7 | @font-face {
8 | font-family: 'My Font';
9 | src: url('/public/fonts/MyFont.woff2') format('woff2');
10 | font-style: normal;
11 | font-weight: 400;
12 | font-display: swap;
13 | }
14 | Font Weights:
15 | 100 - Thin
16 | 200 - Extra Light (Ultra Light)
17 | 300 - Light
18 | 400 - Normal
19 | 500 - Medium
20 | 600 - Semi Bold (Demi Bold)
21 | 700 - Bold
22 | 800 - Extra Bold (Ultra Bold)
23 | 900 - Black (Heavy)
24 | */
25 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | /** @type { import('storybook-vue').StorybookConfig } */
2 | const config = {
3 | stories: [
4 | '../stories/**/*.stories.@(js|jsx|ts|tsx|mdx)',
5 | '../stories/**/*.@(js|jsx|ts|tsx|mdx)'
6 | ],
7 | addons: [
8 | '@storybook/addon-links',
9 | '@storybook/addon-essentials',
10 | '@chromatic-com/storybook'
11 | ],
12 | framework: {
13 | name: '@storybook-vue/nuxt',
14 | options: {}
15 | },
16 | docs: {
17 | autodocs: 'tag'
18 | }
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | // Import fonts.css to load custom fonts for the Storybook preview
2 | import './fonts.css'
3 |
4 | const preview = {
5 | parameters: {
6 | layout: 'fullscreen', // Removes default Storybook padding
7 | controls: {
8 | matchers: {
9 | color: /(background|color)$/i,
10 | date: /Date$/i
11 | }
12 | }
13 | }
14 | }
15 |
16 | export default preview
17 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "prettier.enable": false,
3 | "eslint.useFlatConfig": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll.eslint": "explicit",
6 | "source.organizeImports": "never"
7 | },
8 | "eslint.codeActionsOnSave.mode": "all",
9 | "eslint.codeAction.showDocumentation": {
10 | "enable": true
11 | },
12 | "editor.formatOnSave": false,
13 | "eslint.validate": [
14 | "javascript",
15 | "javascriptreact",
16 | "typescript",
17 | "typescriptreact",
18 | "vue",
19 | "html",
20 | "markdown",
21 | "json",
22 | "jsonc",
23 | "yaml",
24 | "toml",
25 | "css",
26 | "postcss"
27 | ],
28 | }
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fuxt
2 |
3 | A complete Headless WordPress tech stack built on Nuxt 4.
4 |
5 | Works best with the [fuxt-backend](https://github.com/funkhaus/fuxt-backend) WordPress theme and included WordPress optimized components.
6 |
7 | Built by [Funkhaus](http://funkhaus.us/). We normally host on [Flywheel](https://share.getf.ly/n02x5z).
8 |
9 | PS: The name Fuxt comes from [Funkhaus](https://funkhaus.us) and Nuxt. [It's provocative](https://www.youtube.com/watch?v=_eRRab36XLI).
10 |
11 | ## Features
12 |
13 | - TODO
14 |
15 | ## Build Setup
16 |
17 | **This is just a [Nuxt site](https://nuxtjs.org), so it builds and deploys like any other Nuxt project.**
18 |
19 | Works best with the [fuxt-backend](https://github.com/funkhaus/fuxt-backend) WordPress theme as the backend.
20 |
21 | **First step:** Duplicate and rename `.example.env` to `.env`. Define any vars environment needed there.
22 |
23 | ```bash
24 | # install dependencies
25 | $ npm install
26 |
27 | # serve with hot reload at localhost:3000
28 | $ npm run dev
29 |
30 | # serve with hot reload Storybook at localhost:3003
31 | $ npm run storybook
32 |
33 | # build for production and launch server
34 | $ npm run build
35 | $ npm start
36 |
37 | # build Storybook for production
38 | $ npx nuxt storybook build
39 |
40 | # generate static project
41 | $ npm run generate
42 |
43 | ```
44 |
45 | ## Documentation
46 |
47 | For detailed explanation on how things work, checkout [the wiki](https://github.com/funkhaus/fuxt/wiki).
48 |
--------------------------------------------------------------------------------
/TODO-FUXT.md:
--------------------------------------------------------------------------------
1 | ## TODO - Fuxt
2 | - SEO defaults
3 | - Port over default CSS vars
4 | - Remove GQL features
5 | - Get GA plugin working
6 | - Make sure Preview's work
7 | - Get nuxt-fonts working one day!
8 | - Make Storybook working
9 | - Add mock-api.json for easier component testing in Storybook
10 | - add event listener for v-intersected directive (e.g. `@has-entered`)
11 | - Figure out custom gutenberg blocks
12 | - Maybe this helps with better Gutenberg support now: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-serialization-default-parser/
13 | - Get some sort of template and story snippit working:
14 | - https://marketplace.visualstudio.com/items?itemName=exer7um.vue-3-vscode-snippets
15 | - https://medium.com/@tbusser/vue-and-storybook-component-scaffolding-50df77b8d79d
16 |
17 | ## TODO - Fuxt backend
18 | - Update plugin manifest
19 | - Add https://wordpress.org/plugins/wp-openapi/
20 | - Auto favicon step from WP-Easy into fuxt-backend
21 | - Hide WP Open API menu item for non-Devs
22 |
23 | ## Questions for Conrawl
24 | - See GitHub issues on fuxt repo
25 | - How to fetch more data from a watcher? $fetch vs useWpFetch()?
26 |
27 | ## TODO - Base Components
28 | - WpImage
29 | - TODO Background color
30 | - TODO Focal points and color
31 | - TODO Only load video when coming into view
32 | - TODO Has-loaded of video?
33 | - [x] WpMenu
34 | - VideoStage
35 | - Gutenberg (LLM help?)
36 | - [x] WpSeo
37 | - WpControls
38 | - [x] WpLink (No longer needed actually, nuxt-link does it all now)
39 | - SplitText
40 |
41 | ## Usage Notes
42 | - Install VS Code plugin "ESlint"
43 | - Document vueUse and lodash auto import usage
44 |
--------------------------------------------------------------------------------
/app/app.vue:
--------------------------------------------------------------------------------
1 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/app/assets/css/main.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This file is included once, for the entire site.
3 | * It's useful for global styles.
4 | */
5 |
6 | /* Globals */
7 | html {
8 | font-family: var(--font-primary);
9 | font-size: 16px;
10 | color: var(--color-black);
11 | background-color: var(--color-white);
12 | margin: 0;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | font-feature-settings: 'kern' 1;
16 | font-kerning: normal;
17 | }
18 | body {
19 | margin: 0;
20 | }
21 | ::selection {
22 | color: var(--color-white);
23 | background: var(--color-black);
24 | }
25 | h1,
26 | h2,
27 | h3,
28 | h4,
29 | h5 {
30 | font-weight: 400;
31 | }
32 | a {
33 | text-decoration: none;
34 | color: inherit;
35 | transition: color 0.4s;
36 | }
37 | button {
38 | appearance: none;
39 | border: none;
40 | background-color: transparent;
41 | cursor: pointer;
42 | outline: none;
43 | margin: 0;
44 | }
45 | .title {
46 | .line {
47 | display: block;
48 | }
49 | }
50 | .overlay {
51 | position: absolute;
52 | top: 0;
53 | left: 0;
54 | right: 0;
55 | bottom: 0;
56 | background: rgba(0, 0, 0, 0.3);
57 | }
58 | .svg {
59 | path {
60 | transition: fill 0.4s;
61 | }
62 | }
63 |
64 | /* Z-Indexs */
65 | .footer {
66 | z-index: -100;
67 | }
68 | .page {
69 | z-index: 100;
70 | }
71 | .header {
72 | z-index: 200;
73 | }
74 | .menu-panel {
75 | z-index: 300;
76 | }
77 | .global-hamburger {
78 | z-index: 400;
79 | }
80 |
81 | /* Page structure */
82 | .header {
83 | }
84 |
85 | /* Gutenberg overrides */
86 | #content {
87 | /* Margins above/below blocks */
88 | --unit-margin-large: 40px; /* Columns, Gallerys, Blockquotes... */
89 | --unit-margin-small: 20px; /* Everything except P, H{n} and lists. */
90 |
91 | /* Gaps on the side of a block (between browser edge) */
92 | --unit-gutter: var(--unit-gap, 40px);
93 |
94 | /* Gaps inbetween images in gallery */
95 | --unit-gallery-gap: var(--unit-gap, 40px);
96 |
97 | /* Max width of blocks */
98 | --unit-max-width-large: 1400px; /* Default, galleries, images, columns */
99 | --unit-max-width-medium: 1080px; /* Headings (center aligned), Blockquotes */
100 | --unit-max-width-small: 800px; /* Headings (left or right aligned), P, lists */
101 |
102 | /* Specific block overrides */
103 | .core-paragraph + .core-heading {
104 | margin-top: var(--unit-margin-large);
105 | }
106 |
107 | /* Gutenberg breakpoints overrides */
108 | @media (--lt-phone) {
109 | .core-spacer {
110 | display: none;
111 | }
112 | }
113 | }
114 |
115 | /* Animations */
116 | /* .bounce {
117 | animation: bounce 1.5s ease-in-out infinite;
118 | }
119 | @keyframes bounce {
120 | 0%,
121 | 20%,
122 | 50%,
123 | 80%,
124 | 100% {
125 | transform: translateY(0) translateX(-50%);
126 | }
127 | 40% {
128 | transform: translateY(-5px) translateX(-50%);
129 | }
130 | 60% {
131 | transform: translateY(5px) translateX(-50%);
132 | }
133 | } */
134 |
--------------------------------------------------------------------------------
/app/assets/css/media.css:
--------------------------------------------------------------------------------
1 | @custom-media --small-viewport (max-width: 600px);
2 | @custom-media --medium-viewport (min-width: 601px) and (max-width: 1200px);
3 | @custom-media --large-viewport (min-width: 1201px);
4 |
5 | @custom-media --gt-cinema only screen and (min-width: 1800px);
6 | @custom-media --lt-cinema only screen and (max-width: 1799px);
7 | @custom-media --lt-tablet only screen and (max-width: 1024px);
8 | @custom-media --lt-phone only screen and (max-width: 850px);
9 | /* @custom-media --phone-landscape: 'only screen and (max-width: 850px) and (orientation: landscape)'; */
10 | /* @custom-media --pad: "only screen and (min-device-width : 768px) and (max-device-width : 1024px)"; */
11 | @custom-media --has-hover (hover: hover);
12 |
13 | /* Usage example:
14 | @media (--lt-phone) {
15 | background-color: red;
16 | }
17 | */
18 |
--------------------------------------------------------------------------------
/app/assets/css/transitions.css:
--------------------------------------------------------------------------------
1 | /* Fade */
2 | .fade-enter-from,
3 | .fade-leave-to {
4 | opacity: 0;
5 | }
6 | .fade-enter-active,
7 | .fade-leave-active {
8 | transition: opacity 0.4s var(--easing-authentic-motion);
9 | }
10 |
11 | /* Slide left & slide right */
12 | .slide-right-enter-from,
13 | .slide-left-leave-to {
14 | transform: translateX(-100%);
15 | }
16 | .slide-left-enter-from,
17 | .slide-right-leave-to {
18 | transform: translateX(100%);
19 | }
20 | .slide-right-enter-active,
21 | .slide-right-leave-active,
22 | .slide-left-enter-active,
23 | .slide-left-leave-active {
24 | transition: transform 0.4s var(--easing-authentic-motion);
25 | }
26 |
27 | /* Slide up */
28 | .slide-up-enter-active,
29 | .slide-up-leave-active {
30 | transition: transform 0.4s var(--easing-authentic-motion);
31 | }
32 | .slide-up-enter-from {
33 | transform: translateY(100%);
34 | }
35 | .slide-up-enter-to {
36 | transform: translateY(0%);
37 | }
38 | .slide-up-leave {
39 | transform: translateY(0%);
40 | }
41 | .slide-up-leave-to {
42 | transform: translateY(-100%);
43 | }
44 |
45 | /* Slide down */
46 | .slide-down-enter-active,
47 | .slide-down-leave-active {
48 | transition: transform 0.4s var(--easing-authentic-motion);
49 | }
50 | .slide-down-enter-from {
51 | transform: translateY(-100%);
52 | }
53 | .slide-down-enter-to {
54 | transform: translateY(0%);
55 | }
56 | .slide-down-leave {
57 | transform: translateY(0%);
58 | }
59 | .slide-down-leave-to {
60 | transform: translateY(100%);
61 | }
62 |
63 | /* Reveal up */
64 | .reveal-up-enter-active,
65 | .reveal-up-leave-active {
66 | transition: clip-path 0.6s var(--easing-authentic-motion);
67 | > * {
68 | transition:
69 | transform 0.6s var(--easing-authentic-motion),
70 | opacity 0.4s var(--easing-authentic-motion);
71 | }
72 | }
73 | .reveal-up-enter-active {
74 | z-index: 10;
75 | }
76 | .reveal-up-enter-from {
77 | clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0% 100%);
78 | > * {
79 | transform: translateY(100px);
80 | }
81 | }
82 | .reveal-up-enter-to {
83 | clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);
84 | > * {
85 | transform: translateY(0px);
86 | }
87 | }
88 | .reveal-up-leave {
89 | clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);
90 | > * {
91 | transform: translateY(0px);
92 | }
93 | }
94 | .reveal-up-leave-to {
95 | clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
96 | > * {
97 | transform: translateY(-100px);
98 | }
99 | }
100 |
101 | /* Reveal down */
102 | .reveal-down-enter-active,
103 | .reveal-down-leave-active {
104 | transition: clip-path 0.6s var(--easing-authentic-motion);
105 | > * {
106 | transition:
107 | transform 0.6s var(--easing-authentic-motion),
108 | opacity 0.4s var(--easing-authentic-motion);
109 | }
110 | }
111 | .reveal-down-enter-active {
112 | z-index: 10;
113 | }
114 | .reveal-down-enter-from {
115 | clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
116 | > * {
117 | transform: translateY(-100px);
118 | }
119 | }
120 | .reveal-down-enter-to {
121 | clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);
122 | > * {
123 | transform: translateY(0px);
124 | }
125 | }
126 | .reveal-down-leave {
127 | clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);
128 | > * {
129 | transform: translateY(0px);
130 | }
131 | }
132 | .reveal-down-leave-to {
133 | clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0% 100%);
134 | > * {
135 | transform: translateY(100px);
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/app/assets/css/vars.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-white: #ffffff;
3 | --color-black: #000000;
4 |
5 | --unit-100vh: 100vh;
6 | --unit-gap: 40px;
7 | --unit-max-width: 1800px;
8 |
9 | --font-primary: sans-serif;
10 | --font-secondary: serif;
11 |
12 | --easing-authentic-motion: cubic-bezier(0.4, 0, 0.2, 1);
13 |
14 | /* Breakpoints */
15 | @media (--lt-phone) {
16 | --unit-gap: 20px;
17 | }
18 |
19 | /* Handle 100vh on mobile using Dynamic View Height units */
20 | @supports (height: 100dvh) {
21 | --unit-100vh: 100dvh;
22 | }
23 | }
24 |
25 | /* Set Breakpoint name, this is saved into Pina */
26 | :root {
27 | --breakpoint-name: 'desktop';
28 | @media (--gt-cinema) {
29 | --breakpoint-name: 'cinema';
30 | }
31 | @media (--lt-cinema) {
32 | --breakpoint-name: 'desktop';
33 | }
34 | @media (--lt-tablet) {
35 | --breakpoint-name: 'tablet';
36 | }
37 | @media (--lt-phone) {
38 | --breakpoint-name: 'phone';
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/assets/svgs/logo-funkhaus.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/components/global-hamburger.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
34 |
35 |
108 |
--------------------------------------------------------------------------------
/app/components/global-header.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
12 |
13 |
23 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-column.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
32 |
33 |
75 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-columns.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
31 |
32 |
59 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-embed.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
74 |
75 |
87 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-gallery.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
43 |
44 |
140 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-heading.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
35 |
36 |
57 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-image.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
47 |
48 |
95 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-list-item.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
20 |
21 |
25 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-list.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
11 |
12 |
13 |
14 |
47 |
48 |
56 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-paragraph.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
33 |
34 |
58 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-quote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
14 |
15 |
16 |
17 |
46 |
47 |
73 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-spacer.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
19 |
20 |
25 |
--------------------------------------------------------------------------------
/app/components/wp-block/core-video.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
37 |
38 |
61 |
--------------------------------------------------------------------------------
/app/components/wp-content.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
14 |
63 |
64 |
216 |
--------------------------------------------------------------------------------
/app/components/wp-image.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
15 |
27 |
28 |
33 |
34 |
35 |
36 |
37 |
38 |
199 |
200 |
272 |
--------------------------------------------------------------------------------
/app/components/wp-menu-item.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 | {{ props.item?.title || '' }}
8 |
9 |
10 |
21 |
22 |
23 |
24 |
37 |
--------------------------------------------------------------------------------
/app/components/wp-menu.vue:
--------------------------------------------------------------------------------
1 |
2 |
26 |
27 |
28 |
78 |
--------------------------------------------------------------------------------
/app/components/wp-seo.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
72 |
73 |
78 |
--------------------------------------------------------------------------------
/app/composables/useWpFetch.ts:
--------------------------------------------------------------------------------
1 | // Fetch from WP, parse response to camelCase object and return ref
2 | export function useWpFetch(endpoint: string, options: object = {}) {
3 | const baseURL = useRuntimeConfig().public.wordpressApiUrl
4 | const { enabled } = usePreviewMode()
5 |
6 | const response = useFetch(endpoint, {
7 | transform: (data) => {
8 | return keysToCamelCase(data || {})
9 | },
10 | onRequest({ options }) {
11 | // Add credentials to fetch request if preview enabled
12 | if (enabled.value) {
13 | options.credentials = 'include'
14 | }
15 | },
16 | baseURL,
17 | ...options,
18 | server: !enabled.value
19 | })
20 |
21 | return response
22 | }
23 |
--------------------------------------------------------------------------------
/app/error.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Nuxt error page
5 |
6 |
7 |
8 |
9 |
10 |
13 |
--------------------------------------------------------------------------------
/app/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
20 |
--------------------------------------------------------------------------------
/app/middleware/redirect-trailing-slash.global.ts:
--------------------------------------------------------------------------------
1 | import { withTrailingSlash, stringifyQuery } from 'ufo'
2 |
3 | export default defineNuxtRouteMiddleware((to, from) => {
4 | if (!import.meta.client) {
5 | return
6 | }
7 |
8 | const pathEndsWithSlash = to.path.endsWith('/')
9 | let redirectUrl = withTrailingSlash(to.path)
10 |
11 | switch (true) {
12 | case to.path == '/':
13 | // The home page
14 | return
15 | break
16 |
17 | case to.fullPath.includes('sitemap.xml'):
18 | return
19 | break
20 |
21 | case to.path.includes('.'):
22 | // This is most likly a request to a specific file, like filename.png,
23 | // Thus we don't want to add a trailingslash. Storybook does this with iframe.html
24 | return
25 | break
26 |
27 | case !pathEndsWithSlash:
28 | // Add a trailing slash always
29 | redirectUrl = withTrailingSlash(to.path)
30 | if (to.query) {
31 | redirectUrl = `${redirectUrl}?${stringifyQuery(to.query)}`
32 | }
33 | return navigateTo(redirectUrl, { redirectCode: 301 })
34 | break
35 | }
36 | })
37 |
--------------------------------------------------------------------------------
/app/pages/[slug].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | This page is not available.
4 |
5 |
6 | Go back to home
7 |
8 |
9 |
10 |
13 |
14 |
18 |
--------------------------------------------------------------------------------
/app/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Home page here
4 |
5 |
9 |
10 |
13 |
14 |
15 |
16 |
31 |
32 |
48 |
--------------------------------------------------------------------------------
/app/plugins/browser.client.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtPlugin(() => {
2 | const siteStore = useSiteStore()
3 |
4 | // Get browser dimensions
5 | const { y } = useWindowScroll()
6 | const { width: newWinWidth, height: newWinHeight } = useWindowSize()
7 |
8 | // Update scroll direction
9 | watch(y, (newVal, oldVal = 0) => {
10 | siteStore.sTop = newVal
11 |
12 | if (oldVal > newVal || newVal === 0) {
13 | siteStore.scrollDirection = 'up'
14 | }
15 | else {
16 | siteStore.scrollDirection = 'down'
17 | }
18 | }, {
19 | immediate: true
20 | })
21 |
22 | // Update window dimensions
23 | watch([newWinWidth, newWinHeight], () => {
24 | const newBreakpoint = useCssVar('--breakpoint-name')
25 | siteStore.breakpoint = newBreakpoint?.value?.replace(/['"]+/g, '').trim() || 'desktop'
26 |
27 | siteStore.winWidth = newWinWidth.value
28 | siteStore.winHeight = newWinHeight.value
29 | }, {
30 | immediate: true
31 | })
32 |
33 | // Update store referrer. Useful to know where the user came from.
34 | addRouteMiddleware('referrer', async (to, from) => {
35 | if (from.name) {
36 | siteStore.referrer = {
37 | name: from.name,
38 | fullPath: from.fullPath,
39 | path: from.path,
40 | query: from.query,
41 | params: from.params
42 | }
43 | }
44 | }, { global: true })
45 | })
46 |
--------------------------------------------------------------------------------
/app/plugins/init.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtPlugin(async () => {
2 | const siteStore = useSiteStore()
3 | if (siteStore.hasLoaded) {
4 | return
5 | }
6 |
7 | // Populate the store with the settings from the WP API
8 | await useSiteStore().init()
9 |
10 | // Configure NuxtLink defaults
11 | defineNuxtLink({
12 | activeClass: 'active-link',
13 | exactActiveClass: 'exact-active-link',
14 | prefetch: true,
15 | trailingSlash: 'append',
16 | prefetchedClass: 'prefetched-link'
17 | })
18 |
19 | siteStore.hasLoaded = true
20 | })
21 |
--------------------------------------------------------------------------------
/app/stores/site.ts:
--------------------------------------------------------------------------------
1 | export const useSiteStore = defineStore('site', () => {
2 | const settings = ref({})
3 | const menuOpened = ref(false)
4 | const breakpoint = ref('desktop')
5 | const referrer: Ref = ref(false)
6 | const scrollDirection = ref('up')
7 | const sTop = ref(0)
8 | const winHeight = ref(0)
9 | const winWidth = ref(0)
10 | const hasLoaded = ref(false)
11 |
12 | // Setup default store settings values
13 | settings.value = {
14 | title: '',
15 | description: '',
16 | backendUrl: '',
17 | frontendUrl: '',
18 | themeScreenshotUrl: '',
19 | sociaMedia: [],
20 | googleAnalytics: [],
21 | socialSharedImage: {}
22 | }
23 |
24 | // Populate state from WP API
25 | const init = async () => {
26 | // Do requests in parallel
27 | const settingsReq = useWpFetch('/settings')
28 | const acfReq = useWpFetch('/acf-options?name=Site Options')
29 | const [settingsRes, acfRes] = await Promise.all([settingsReq, acfReq])
30 |
31 | // Get ref values
32 | const settingsData = settingsRes.data?.value || {}
33 | const acfData = acfRes.data?.value || {}
34 |
35 | // Save to store
36 | settings.value = { ...settingsData, ...acfData }
37 | }
38 |
39 | return {
40 | settings,
41 | menuOpened,
42 | breakpoint,
43 | sTop,
44 | winHeight,
45 | winWidth,
46 | referrer,
47 | scrollDirection,
48 | hasLoaded,
49 | init
50 | }
51 | })
52 |
--------------------------------------------------------------------------------
/app/types/global-hamburger.d.ts:
--------------------------------------------------------------------------------
1 | export type GlobalHamburgerProps = {
2 | menuOpened: boolean
3 | }
4 |
--------------------------------------------------------------------------------
/app/types/index.d.ts:
--------------------------------------------------------------------------------
1 | export * from './global-hamburger'
2 |
--------------------------------------------------------------------------------
/app/utils/buildVideoEmbedUrl.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This function builds out a Vimeo iFrame embed URL
3 | *
4 | * @param {string} url - The url to the Vimeo or YouTube page, eg: https://vimeo.com/20744468/12345678 or https://vimeo.com/20744468 or https://www.youtube.com/watch?v=zAGu2TPt_78
5 | * @param {Record} options - Object of optional embed parameters.
6 | * @returns {string}
7 | */
8 | function buildVideoEmbedUrl(url: string = '', options: Record = {}): string {
9 | let output: string = url
10 |
11 | switch (true) {
12 | case isVimeo(url):
13 | output = buildVimeoUrl(url, options)
14 | break
15 |
16 | case isYouTube(url):
17 | output = buildYouTubeUrl(url, options)
18 | break
19 | }
20 |
21 | return output
22 | }
23 |
24 | /**
25 | * Convert a Vimeo video page URL into an iFrame embed URL
26 | *
27 | * @param {string} url - Vimeo video page URL
28 | * @param {Record} options - Optional Vimeo embed parameter options.
29 | * @returns {string}
30 | */
31 | function buildVimeoUrl(url: string = '', options: Record = {}): string {
32 | // Set defaults and merge with provided options
33 | const defaults: Record = {
34 | byline: 0,
35 | portrait: 0,
36 | autoplay: 1,
37 | color: 'ffffff',
38 | controls: 1,
39 | playsinline: 1,
40 | api: 1,
41 | dnt: 0,
42 | title: 0,
43 | autopause: 1
44 | }
45 | const parameters: Record = { ...defaults, ...options }
46 |
47 | // Parse URL, remove query params, set new player hostname
48 | const parsedUrl = new URL(url)
49 | parsedUrl.search = ''
50 | parsedUrl.hostname = 'player.vimeo.com'
51 |
52 | // Get Video ID and Privacy Hash from paths
53 | const paths = parsedUrl.pathname.split('/').filter(Boolean)
54 |
55 | // Set ID
56 | parsedUrl.pathname = `/video/${paths[0] || ''}`
57 |
58 | // Set privacy hash
59 | if (paths[1]) {
60 | parsedUrl.searchParams.set('h', paths[1])
61 | }
62 |
63 | // Add all options as query params to URL
64 | return setUrlParameters(parsedUrl, parameters).toString()
65 | }
66 |
67 | /**
68 | * Convert a YouTube video page URL into an iFrame embed URL
69 | *
70 | * @param {string} url - YouTube video page URL
71 | * @param {Record} options - Optional YouTube embed parameter options.
72 | * @returns {string}
73 | */
74 | function buildYouTubeUrl(url: string, options: Record): string {
75 | // Set defaults and merge with provided options
76 | const defaults: Record = {
77 | rel: 0,
78 | autoplay: 1,
79 | color: 'ffffff',
80 | controls: 1,
81 | playsinline: 1,
82 | enablejsapi: 1,
83 | modestbranding: 1,
84 | loop: 0
85 | }
86 | const parameters: Record = { ...defaults, ...options }
87 |
88 | // Get YouTube ID
89 | const youTubeId: string = getYouTubeId(url)
90 |
91 | // Parse URL, remove any query params
92 | const parsedUrl = new URL(url)
93 | parsedUrl.search = ''
94 |
95 | // Set ID
96 | parsedUrl.pathname = `/embed/${youTubeId}`
97 |
98 | // Add all options as query params to URL
99 | return setUrlParameters(parsedUrl, parameters)
100 | .toString()
101 | .replace('https://youtu.be/', 'https://www.youtube.com/')
102 | }
103 |
104 | /**
105 | * Function to add an object of URL parameters to a URL
106 | *
107 | * @param {URL} url - A URL interface object
108 | * @param {Record} parameters - Object of URL parameter key:value pairs
109 | * @returns {URL} - A URL interface with updated parameters
110 | */
111 | function setUrlParameters(url: URL, parameters: Record = {}): URL {
112 | Object.entries(parameters).forEach(([key, value]) => {
113 | // Cast a true/false to 1/0 to be URL friendly
114 | if (typeof value === 'boolean') {
115 | url.searchParams.set(key, Number(value).toString())
116 | }
117 | else {
118 | url.searchParams.set(key, String(value))
119 | }
120 | })
121 |
122 | return url
123 | }
124 |
125 | /**
126 | * Tests a URL string for Vimeo
127 | * @param {string} url
128 | * @returns {boolean}
129 | */
130 | function isVimeo(url: string = ''): boolean {
131 | return url.includes('vimeo.com')
132 | }
133 |
134 | /**
135 | * Tests a URL string for YouTube
136 | * @param {string} url
137 | * @returns {boolean}
138 | */
139 | function isYouTube(url: string = ''): boolean {
140 | return url.includes('youtube.com') || url.includes('youtu.be')
141 | }
142 |
143 | /**
144 | * Gets a YouTube ID from a URL string
145 | * @param {string} url
146 | * @returns {string}
147 | */
148 | function getYouTubeId(url: string = ''): string {
149 | const regex = /youtu(?:.*\/v\/|.*v\=|\.be\/)([A-Za-z0-9_\-]{11})/
150 | const matches = url.match(regex)
151 | return matches ? matches[1] : ''
152 | }
153 |
154 | export default buildVideoEmbedUrl
155 |
--------------------------------------------------------------------------------
/app/utils/decodeHtmlEntities.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * This function is used to decode HTML entities. Useful for setting head title tags.
3 | * Will convert ``–`` into `-` for example.
4 | */
5 | function decodeHtmlEntities(string = '') {
6 | return string
7 | .replace(/(\d+);/g, function (match, dec) {
8 | return String.fromCharCode(dec)
9 | })
10 | .replace(/&/g, '&')
11 | }
12 |
13 | export default decodeHtmlEntities
14 |
--------------------------------------------------------------------------------
/app/utils/delay.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Used as an async/await friendly alternative to setTimeout
3 | */
4 | function delay(time: number = 0): Promise {
5 | return new Promise((res) => {
6 | setTimeout(res, time)
7 | })
8 | }
9 |
10 | export default delay
11 |
--------------------------------------------------------------------------------
/app/utils/emdashed.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Replace hyphen with emdash
3 | */
4 | function emdashed(value: string = ''): string {
5 | if (!value) {
6 | return ''
7 | }
8 |
9 | // Replace hyphen with emdash if value is a string
10 | if (typeof value === 'string' || value instanceof String) {
11 | value = value.replace(/ – /g, ' — ').replace(/ - /g, ' — ')
12 | }
13 |
14 | return value
15 | }
16 |
17 | export default emdashed
18 |
--------------------------------------------------------------------------------
/app/utils/getCookie.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Get a cookie. Used by custom video player.
3 | * SEE https://stackoverflow.com/a/24103596/503546
4 | */
5 | function getCookie(name: string): string | null {
6 | const nameEQ = `${name}=`
7 | const cookiesArray = document.cookie.split(';')
8 |
9 | for (let i = 0; i < cookiesArray.length; i++) {
10 | const cookie = cookiesArray[i].trim()
11 | if (cookie.indexOf(nameEQ) === 0) {
12 | return cookie.substring(nameEQ.length, cookie.length)
13 | }
14 | }
15 |
16 | return null
17 | }
18 |
19 | export default getCookie
20 |
--------------------------------------------------------------------------------
/app/utils/keysToCamelCase.ts:
--------------------------------------------------------------------------------
1 | // Convert object keys to camelCase
2 | // TODO Fix typescript errors in this
3 | function keysToCamelCase(obj: unknown): unknown {
4 | if (Array.isArray(obj)) {
5 | return obj.map(v => keysToCamelCase(v))
6 | }
7 | else if (obj != null && obj.constructor === Object) {
8 | return Object.keys(obj).reduce(
9 | (result, key) => ({
10 | ...result,
11 | [_CamelCase(key)]: keysToCamelCase(obj[key])
12 | }),
13 | {}
14 | )
15 | }
16 | return obj
17 | }
18 |
19 | export default keysToCamelCase
20 |
--------------------------------------------------------------------------------
/app/utils/setCookie.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Set a cookie. Used by custom video player.
3 | * SEE https://stackoverflow.com/a/24103596/503546
4 | */
5 | function setCookie(name: string, value: string, days?: number): void {
6 | let expires = ''
7 | if (days) {
8 | const date = new Date()
9 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000)
10 | expires = '; expires=' + date.toUTCString()
11 | }
12 | document.cookie = `${name}=${value || ''}${expires}; path=/`
13 | }
14 |
15 | export default setCookie
16 |
--------------------------------------------------------------------------------
/app/utils/splitAtDash.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Splits a string by the separator and optionally keeps the separator in the result.
3 | * Used primarily for separating text at an emdash (—).
4 | */
5 | // TODO: refactor this function to use a regular expression instead of a string for separator
6 | function splitAtDash(text: string = '', separator: string = ' — ', keepSeparator: boolean = false): string[] {
7 | let output = text.split(separator)
8 |
9 | // Add separator back into the array
10 | // This is useful if separating by an opening quote
11 | if (keepSeparator) {
12 | output = output.map((element, index) => {
13 | if (index > 0) {
14 | return ` ${separator} ` + element
15 | }
16 | return element
17 | })
18 | }
19 |
20 | return output
21 | }
22 |
23 | export default splitAtDash
24 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
2 | import pluginJsonc from 'eslint-plugin-jsonc'
3 | import parserJsonc from 'jsonc-eslint-parser'
4 |
5 | export default createConfigForNuxt({
6 | features: {
7 | stylistic: {
8 | semi: false,
9 | indent: 4,
10 | quotes: 'single',
11 | commaDangle: 'never'
12 | },
13 | formatters: true,
14 | typescript: true
15 | }
16 | }).append(
17 | {
18 | files: ['**/*.json'],
19 | plugins: {
20 | jsonc: pluginJsonc
21 | },
22 | languageOptions: {
23 | parser: parserJsonc
24 | },
25 | name: 'jsonc/rules',
26 | rules: {
27 | ...pluginJsonc.configs['flat/recommended-with-json'].rules,
28 | 'jsonc/indent': ['error', 4]
29 | }
30 | }
31 | )
32 |
--------------------------------------------------------------------------------
/example.env:
--------------------------------------------------------------------------------
1 | WORDPRESS_API_URL = "https://fuxt-backend.funkhaus.us/wp-json/fuxt/v1/"
2 | HOST = "0.0.0.0"
3 | STORYBOOK = true
4 |
--------------------------------------------------------------------------------
/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | // https://nuxt.com/docs/api/configuration/nuxt-config
2 | export default defineNuxtConfig({
3 |
4 | // Modules and configuration
5 | modules: [
6 | '@pinia/nuxt',
7 | 'nuxt-svgo',
8 | '@nuxt/fonts',
9 | '@nuxtjs/storybook',
10 | '@nuxtjs/sitemap',
11 | '@vueuse/nuxt',
12 | 'nuxt-lodash'
13 | ],
14 | devtools: {
15 | // This only ships on dev, it gets stripped in Production
16 | enabled: true
17 | },
18 |
19 | // Nuxt app configuration
20 | app: {
21 | head: {
22 | meta: [
23 | { charset: 'utf-8' },
24 | {
25 | name: 'viewport',
26 | content: 'width=device-width, initial-scale=1'
27 | }
28 | ],
29 | link: [
30 | {
31 | rel: 'icon',
32 | type: 'image/png',
33 | href: '/favicon.png'
34 | }
35 | ]
36 | },
37 | pageTransition: {
38 | name: 'fade',
39 | mode: 'out-in'
40 | }
41 | },
42 |
43 | // CSS and fonts
44 | css: [
45 | '~/assets/css/vars.css',
46 | '~/assets/css/main.css',
47 | '~/assets/css/transitions.css'
48 | ],
49 | vue: {
50 | // Required for @nuxtjs/storybook
51 | runtimeCompiler: process.env.STORYBOOK === 'true'
52 | },
53 |
54 | // Runtime ENV parsing
55 | runtimeConfig: {
56 | public: {
57 | wordpressApiUrl: process.env.WORDPRESS_API_URL
58 | }
59 | },
60 | future: { compatibilityVersion: 4 },
61 | compatibilityDate: '2024-09-17',
62 |
63 | // Build configuration
64 | nitro: {
65 | routeRules: {
66 | // All routes should be ISR
67 | '/**': {
68 | isr: true
69 | }
70 | },
71 | prerender: {
72 | // This helps ensure that all paths end with `/`.
73 | autoSubfolderIndex: true,
74 | crawlLinks: true
75 | },
76 | compressPublicAssets: {
77 | gzip: true
78 | }
79 | },
80 | vite: {
81 | optimizeDeps: {
82 | // Used for v8.3.5 of Storybook. Can remove after update.
83 | // SEE https://github.com/nuxt-modules/storybook/issues/776
84 | include: ['jsdoc-type-pratt-parser']
85 | }
86 | },
87 | postcss: {
88 | plugins: {
89 | '@csstools/postcss-global-data': {
90 | files: ['./app/assets/css/media.css']
91 | },
92 | 'postcss-nested': {},
93 | 'postcss-custom-media': {}
94 | }
95 | },
96 | fonts: {
97 | defaults: {
98 | weights: [100, 200, 300, 400, 500, 600, 700, 800, 900]
99 | },
100 | experimental: {
101 | // Must be enabled to support processing fonts as CSS vars
102 | processCSSVariables: true
103 | }
104 | },
105 | lodash: {
106 | prefix: '_',
107 | prefixSkip: []
108 | },
109 |
110 | server: {
111 | host: process.env.HOST || '0.0.0.0'
112 | },
113 | sitemap: {
114 | exclude: ['/wp-admin/']
115 | },
116 | svgo: {
117 | autoImportPath: './assets/svgs/',
118 | defaultImport: 'component',
119 | componentPrefix: 'svg',
120 | }
121 | })
122 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fuxt",
3 | "version": "4.0.0",
4 | "private": true,
5 | "type": "module",
6 | "engines": {
7 | "node": "22.x"
8 | },
9 | "scripts": {
10 | "build": "nuxt build",
11 | "dev": "nuxt dev",
12 | "lint": "eslint .",
13 | "format": "eslint . --fix",
14 | "generate": "nuxt generate",
15 | "preview": "nuxt preview",
16 | "postinstall": "nuxt prepare",
17 | "storybook": "STORYBOOK=true storybook dev -p 6006",
18 | "build-storybook": "STORYBOOK=true storybook build"
19 | },
20 | "dependencies": {
21 | "nuxt": "^3.13.2"
22 | },
23 | "devDependencies": {
24 | "@chromatic-com/storybook": "^3.2.4",
25 | "@csstools/postcss-global-data": "^3.0.0",
26 | "@nuxt/eslint-config": "^0.7.0",
27 | "@nuxt/fonts": "^0.10.0",
28 | "@nuxtjs/sitemap": "6.0.1",
29 | "@nuxtjs/storybook": "^8.2.0",
30 | "@pinia/nuxt": "^0.5.1",
31 | "@storybook/addon-essentials": "^8.3.0",
32 | "@storybook/addon-links": "^8.3.0",
33 | "@storybook/vue3": "^8.3.0",
34 | "@types/node": "^22.5.5",
35 | "@vueuse/core": "^11.1.0",
36 | "@vueuse/nuxt": "^11.1.0",
37 | "eslint": "^9.14.0",
38 | "eslint-plugin-format": "^0.1.2",
39 | "eslint-plugin-jsonc": "^2.18.2",
40 | "jsonc-eslint-parser": "^2.4.0",
41 | "nuxt-lodash": "^2.5.3",
42 | "nuxt-svgo": "^4.0.6",
43 | "pinia": "^2.1.7",
44 | "postcss": "^8.4.47",
45 | "postcss-custom-media": "^11.0.1",
46 | "postcss-nested": "^6.2.0",
47 | "prettier": "^3.3.3",
48 | "storybook": "^8.3.0",
49 | "typescript": "^5.6.3",
50 | "vue": "^3.4.36"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funkhaus/fuxt/acc976eeb6548ef7448c2df475a8681baacda8d0/public/favicon.png
--------------------------------------------------------------------------------
/stories/global-header/Docs.mdx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funkhaus/fuxt/acc976eeb6548ef7448c2df475a8681baacda8d0/stories/global-header/Docs.mdx
--------------------------------------------------------------------------------
/stories/global-header/global-header.stories.ts:
--------------------------------------------------------------------------------
1 | import GlobalHeader from '~/components/global-header.vue'
2 |
3 | export default {
4 | title: 'global-header'
5 | }
6 |
7 | export const Default = () => ({
8 | components: { GlobalHeader },
9 | template: ``
10 | })
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://nuxt.com/docs/guide/concepts/typescript
3 | "extends": "./.nuxt/tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------