nuxt.jssingle-page-applicationnuxt3.jswordpress-rest-apidynamic-content

Nuxt3 - How to create a single page portfolio site where posts open in a div popup (no page reload), but each project has a unique url?


I am making a portfolio website with nuxt3, tailwindcss and the Wordpress REST API.

The general layout of the portfolio website is as follows:

<body>
  <div id="page-wrapper">
    <section id="landing-image" class="w-screen h-screen">...</section>
    <section id="about">...</section>
    <section id="projects-grid">...</section>
  </div>
</body>

Goal:

I want the website to feel like it is only one page, but support clicking on a project from the project grid to read more about the project. When clicking on a project, the content should load without a (visible) reload of the page. See this portfolio site as an example of the project grid with project popup that I try to achieve: https://www.tessarosejacksoncomposer.com/

What I have tried:

1.) One way I thought of doing this, is by adding a div to the page that contains the post content which is hidden by tailwind and nuxt keeps track of the state. Nuxt conditionally sets the 'hidden' class when the post needs to be seen or not and the the correct project content is loaded.

app.vue

<template>
  <div id="page-wrapper">
    <section id="landing-image" class="w-screen h-screen">...</section>
    <section id="about">...</section>
    <section id="projects-grid">...</section>
  </div>
  <div id="project-content-wrapper" class="absolute h-screen w-screen" :class="{'hidden': postHidden }">
    ... (dynamically load project content and unhide by nuxt state management) ...
  </div>
</template>

This is a nice and simple implementation, but my main problem with it is that each project does not have a unique url. When I want to share a specific project, I want to send a url that opens the project imediatly. Now the url always stays on the home page. Second problem with this is that there is no url history when using the site, which I think is not intuitive.

2.) So, I found a second way of doing this using nested nuxt pages and the <NuxtPage /> component (reference: https://v2.nuxt.com/examples/routing/nested-pages/ ). My idea was to put the <NuxtPage /> tag in the project content div and then have a nested page that changes based on the url, but does not reload the entire page (visibly). In the pages/ directory I created a project/ directory, project.vue file and in the project/ directory I added a [...slug].vue file that contains the project layout and renders the content. This way my projects have the url example.com/project/project-slug. The project.vue file is only a passthrough to get a nice looking url and contains only a template with a <NuxtPage /> to nest the project children.

app.vue

<template>
  <div id="page-wrapper">
    <section id="landing-image" class="w-screen h-screen">...</section>
    <section id="about">...</section>
    <section id="projects-grid">...</section>
  </div>
  <div id="project-content-wrapper" class="absolute h-screen w-screen" :class="{'hidden': postHidden }">
    <NuxtPage />
  </div>
</template>

This second method solved the url issue, but the page visibly reloads and breaks the single page effect. That is because the page can scroll and when going to a project using <NuxtLink>, the site scrolls to the top. This becomes a problem when I add a cross UI element for closing the project pop up and going back to the home page. When clicking on the cross, then the page is on top, forcing the user to scroll back down again to the project grid. I want the illusion that the popup opens over the site and when clicking on the cross, the user continues where they left off.

Question:

Does anyone know a way to have achieve this project grid popup effect with unique urls for every project while maintaining the feeling of staying on the same single page?


Solution

  • The suggestions of @lousolverson and @Mojtaba, and a lucky find of the term 'Modal' instead of what I called a 'popup', send me down a rabbit hole and I eventually was able to get the behavior that I want.

    My solution builds further on my 1st attempt , but using the navigateTo composable of Nuxt3. This composable is a nicer way to programmatically change the url than the window.history.pushstate and also does not reload the page on url change.

    The state of the postmodal is stored using the useState composable, which is false if the user is on the home page with the projectsgrid and the modal is closed. Then if the user clicks on a ProjectCard, then this state changes to the slug of the selected project and the projectModal will open with the content of the selected project.

    If someone gets a url that directly leads to a project, then the onMounted() function will set the correct state and scroll the underlying page to the projectsgrid. Also, the url history is preserved. So, the site will give a fluent experience without page reloads, but supporting unique urls for each project.

    Thank you for the suggestions and I hope someone else may find this useful! :)

    app.vue

    <script setup>
      const wordpress_host = 'http://localhost/wp-json/wp/v2'
      const { data:projects } = await useFetch(wordpress_host+'/posts');
    
      const route = useRoute();
    
      const showProjectModal = useState('showProjectModal', () => false);
    
      onMounted(() => {
        if (route.params.slug) {
          showProjectModal.value = route.params.slug[0];
          document.getElementById('projects').scrollIntoView({ behavior: 'smooth' });
        }
      });
    </script>
    
    <template>
      <main class="bg-black text-white"> 
        <div id="page-wrapper" class="h-screen w-screen overflow-scroll" :class="{ 'overflow-hidden blur-sm': showProjectModal }">
    
          <section id="hero" class="relative h-screen w-screen">...</section>
          <section id="about" class="w-screen py-12">...</section>
    
          <section id="projects" class="w-screen py-12 sm:px-2">
            <div class="min-w-screen-lg grid 2xl:grid-cols-6 xl:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 grid-cols-1">
              <PostCard v-for="project,index in projects" :key="'project'+index" v-bind:project="project" v-bind:wordpress_host="wordpress_host" />
            </div>
    
          </section>
    
        </div>
          <ProjectModal :project="projects.find(project => { return project.slug === showProjectModal })" :wordpress_host="wordpress_host" />
      </main>
    </template>
    

    components/ProjectCard.vue

    <script setup>
      const props = defineProps(['project', 'wordpress_host']);
    
      const showProjectModal = useState('showProjectModal', () => false);
      const openProjectModal = (slug) => {
        showProjectModal.value = slug;
        navigateTo(`/project/${slug}`);
      };
    </script>
    
    <template>
      <article>
        <div class="relative aspect-square">
          <img :src="..." />
          <div @click="openProjectModal(props.project.slug)" class="p-4 absolute h-full inset-0 bg-gray-700 cursor-pointer hover:bg-black/60"">
           ... card content overlayed on top of image ...
          </div>
        </div>
      </article>
    </template>
    

    components/ProjectModal.vue

    <template>
      <div>
        <transition
            enter-active-class="transition-opacity duration-200 ease-out"
            enter-from-class="opacity-0"
            enter-to-class="opacity-100"
            leave-active-class="transition-opacity duration-100 ease-in"
            leave-from-class="opacity-100"
            leave-to-class="opacity-0"
        >
        <div id="projectmodal-overlay" v-show="showProjectModal" class="fixed inset-0 z-50 grid place-items-center w-screen h-screen bg-black/80 px-2 py-4 md:px-16 md:py-9 lg:px-32 lg:py-18">
          <div id="projectmodal-modalbox" class="w-full h-full m-auto flex flex-col overflow-hidden">
            <header class="bg-gray-900 h-16 px-4">
              <button @click="closeProjectModal()" class="h-full text-4xl">&#10005</button>
            </header>
            <div id="projectmodal-contentbox" v-if="showProjectModal">
              ... <!-- v-if needed when using transitions, because the url will already change and so the content is removed before the transition is done, which may result in errors -->
            </div>
          </div>
        </div>
        </transition>
      </div>
    </template>
    
    <script setup>
      const props = defineProps(['project', 'wordpress_host']);
    
      const route = useRoute();
      const showProjectModal = useState('showProjectModal', () => false);
      const closeProjectModal = () => {
        showProjectModal.value = false;
        navigateTo(`/`);
      };
    </script>