Jump to content
schwarzdesign

Architekturführer Köln - SPA in the front, ProcessWire in the back

Recommended Posts

We recently rebuilt the Architekturführer Köln (architectural guide Cologne) as a mobile-first JavaScript web app, powered by VueJS in the frontend and ProcessWire in the backend. Concept, design and implementation by schwarzdesign!

The Architekturführer Köln is a guidebook and now a web application about architectural highlights in Cologne, Germany. It contains detailled information about around 100 objects (architectural landmarks) in Cologne. The web app offers multiple ways to search through all available objects, including:

  • An interactive live map
  • A list of object near the user's location
  • Filtering based on architect, district and category
  • Favourites saved by the user

The frontend is written entirely in JavaScript, with the data coming from a ProcessWire-powered API-first backend.

Frontend

The app is built with the Vue framework and compiled with Webpack 4. As a learning exercise and for greater customizability we opted to not use Vue CLI, and instead wrote our own Webpack config with individually defined dependencies.

The site is a SPA (Single Page Application), which means all internal links are intercepted by the Vue app and the corresponding routes (pages) are generated by the framework directly in the browser, using data retrieved from the API. It's also a PWA (Progressive Web App), the main feature of which is that you can install it to your home screen on your phone and launch it from there like a regular app. It also includes a service worker which catches requests to the API and returns cached responses when the network is not available. The Architekturführer is supposed to be taken with you on a walk through the city, and will keep working even if you are completely offline.

Notable mentions from the tech stack:

  • Vue
  • Vue Router for the SPA functionality
  • VueX for state management and storage / caching of the data returned through the API
  • Leaflet (with Mapbox tiles) for the interactive maps
  • Webpack 4 for compilation of the app into a single distributable
  • Babel for transpilation of ES6+
  • SASS & PostCSS with Autoprefixer as a convenience for SASS in SFCs
  • Google Workbox to generate the service worker instead of writing lots of boilerplate code
  • Bootstrap 4 is barely used here, but we still included it's reboot and grid system

Backend

The ProcessWire backend is API-only, there are no server-side rendered templates, which means the only PHP template is the one used for the API. For this API, we used a single content type (template) with a couple of pre-defined endpoints (url segments); most importantly we built entdpoints to get a list of all objects (either including the full data, or only the data necessary to show teaser tiles), as well as individual objects and taxonomies. The API template which acts as a controller contains all the necessary switches and selectors to serve the correct response in <100 lines of code.

Since we wanted some flexibility regarding the format in which different fields were transmitted over the api, we wrote a function to extract arbitrary page fields from ProcessWire pages and return them as serializable standard objects. There's also a function that takes a Pageimage object, creates multiple variants in different sizes and returns an object containing their base path and an array of variants (identified by their basename and width). We use that one to generate responsive images in the frontend. Check out the code for both functions in this gist.

We used native ProcessWire data wherever possible, so as to not duplicate that work in the frontend app. For example:

  • Page names from the backend translate to URLs in the frontend in the form of route parameters for the Vue Router
  • Page IDs from ProcessWire are included in the API responses, we use those to identify objects across the app, for example to store the user's favourites, and as render keys for object lists
  • Taxonomies have their own API endpoints, and objects contain their taxonomies only as IDs (in the same way ProcessWire uses Page References)

Finally, the raw JSON data is cached using the cache API and this handy trick by @LostKobrakai to store raw JSON strings over the cache API.

Screenshots

architekturfuehrer_front_2_karte.png

architekturfuehrer_front_1_home.png

architekturfuehrer_front_3_object.png

architekturfuehrer_front_4_object_map.png

architekturfuehrer_front_5_search.png

architekturfuehrer_front_6_page.png

architekturfuehrer_back_1_home.png

architekturfuehrer_back_2_object.png

architekturfuehrer_back_3_object_images.png

architekturfuehrer_back_4_taxonomies.png

architekturfuehrer_back_5_object_template.png

architekturfuehrer_back_6_api.png

architekturfuehrer_back_7_api_response.png

lighthouse_report.png

  • Like 32

Share this post


Link to post
Share on other sites

This looks absolutely brilliant! Congratulations for this great work and thanks for sharing it with us!

I've also tried it on my phone and everything just worked 🙂 One thing that was counter intuitive for me was the left arrow icon on the top left corner of the frontpage. On all other pages this icon means "back", on the frontpage there is no "back" of course, but I didn't realize that I was on the frontpage, so it might make sense to remove the icon there?

Another thing: I wanted to share the app with a friend, but the share icon is only available on sub-pages.

  • Like 3
  • Thanks 1

Share this post


Link to post
Share on other sites

Beautiful color contrasts and switching between them
Responsive made with proportional resizing
Pictures going out of focus into the background
The first not irritating popups I have ever seen
-- Einfach WoW --

  • Like 1
  • Thanks 1

Share this post


Link to post
Share on other sites

@netcarver Thanks!

@bernhard Thank you for the QA 😀 Regarding the "back" button, I tried to make it more intelligent, but I got stuck on the history API not having a reliable way to check the last items. I thought about turning it off only on the homepage, but I wanted to have a way to go back to the last page if you go e.g. from an object detail page to the homepage and want back to the object. I'd have to track the last history states manually, since the Vue Router just wraps around the history API and doesn't keep a memory of itself. I'll put that back on the to do list!

As for the share button, we only have that on the object pages in the subnavigation; it would take up to much space too have it on the bottom all the time. Maybe we can put it at the end of every page, or in the navigation. Though it's only supported in Chrome on Android and Safari at the moment, and most browsers have their own native sharing button, so it wasn't the most important feature to us.

@pwired Thank you so much! 🙂

  • Like 2

Share this post


Link to post
Share on other sites

I understand, thx for the explanation 🙂 What program did you use for the mobile screenshots?

  • Like 1

Share this post


Link to post
Share on other sites
38 minutes ago, bernhard said:

I understand, thx for the explanation 🙂 What program did you use for the mobile screenshots?

@bernhard It's a little trick you can do with Google Chrome: In the responsive view mode, select one of the iPhone models in the responsive mode settings bar. In the flyout menu on the upper right, there's an option "show device frame". Only works with some of the devices, I've used "iPhone 6/7/8 Plus". If you take a screenshot (via the same flyout menu) while in responsive design mode with the show device frame option turned on, the device frame will be included in the screenshot.

  • Like 3
  • Thanks 3

Share this post


Link to post
Share on other sites

Nice site, the only issue I came across is scrolling over the map is impossible with the mouse (the map gets zoomed instead).

  • Like 2
  • Thanks 1

Share this post


Link to post
Share on other sites
13 minutes ago, tpr said:

Nice site, the only issue I came across is scrolling over the map is impossible with the mouse (the map gets zoomed instead).

@tpr Thanks! I see your point; I recently wrote a little snippet for leaflet to only activate mouse scrolling after clicking anywhere inside the map, maybe that would make sense here too. I'll put in on the to do list!

Share this post


Link to post
Share on other sites
1 hour ago, tpr said:

Nice site, the only issue I came across is scrolling over the map is impossible with the mouse (the map gets zoomed instead).

Yes. Same here. Also on mobile it is a little annoying. Google does it like this:

Ctrl+Scroll for Desktop:

JCHqyrA.png

2-finger-scroll for mobile:

1ctUQHz.png

  • Like 1
  • Thanks 1

Share this post


Link to post
Share on other sites

Sorry for being late to the party but I had to look around on that site.

As already said here... Awesome!

 

  • Thanks 1

Share this post


Link to post
Share on other sites

Thanks for the feedback everyone, I just made some updates to the app! You may have to fully clear the cache to see the changes immediately, as with the service worker the caching is pretty aggressive.

@bernhard I have tweaked the functionality of the back button. The app now keeps track of the page history independently of the browser history API and only displays the back button if there are at least to items in the history stack. So you shouldn't see the back button on the first page visit, regardless of which route you're on.

@tpr @bernhard For now, I have just disabled scroll wheel zoom for most of the embedded maps; this way, the map won't interfere with the normal page scrolling. The map can still be zoomed with the buttons to the left, and scrool zooming activates after clicking inside the map. Pinch zooming on touch devices should also work as normal. The only exception is the map page (architekturfuehrer.koeln/karte) which can always be zoomed with the scroll wheel; but that page should always fill the entire viewport with nothing to scroll below that, so it shouldn't be an issue there.

  • Like 4

Share this post


Link to post
Share on other sites

Congrats, looks amazing and performs super fast.

Just a short question. Did you have any concerns about SEO impact since everything is done through VUE and there is no real source coude there?

  • Like 1

Share this post


Link to post
Share on other sites
5 minutes ago, Moebius said:

concerns about SEO impact

Googlebot is clever these days. JS-content is no problem for indexing anymore (React, Angular, Vue etc.). Meta tags and page titles are adjusted for every page-view (something a lot of developers simply forget when building SPAs). Of course, you could always improve SEO, e.g. creating a sitemap.xml...

https://www.smashingmagazine.com/2019/05/vue-js-seo-reactive-websites-search-engines-bots/

^ a good read about the subject

 

  • Like 3

Share this post


Link to post
Share on other sites

hey @schwarzdesign, so pleased to see you finally found your way to processwire! awesome work i enjoy frequently on my mobile.

  • Like 1

Share this post


Link to post
Share on other sites

Same experience I got when I wanted to try it on mobile. I thought that was because it was too new when I tried first 🙂 

b2sl3qm.png

  • Like 1

Share this post


Link to post
Share on other sites

@Moebius @dragan @bernhard Well, Google's crawlers can run JS, they even updated the Chromium version their crawlers run on a couple of weeks back, so it should have no problem with the app. architekturfuehrer.koeln ist also an entirely new domain we set up for the app, so there was nothing in the index to begin with. We'll see how the coverage is progressing going forward. I did built a sitemap to help with indexing the site by the way: https://architekturfuehrer.koeln/sitemap. This one is created server-side, though ...

Share this post


Link to post
Share on other sites

Thanks for the detailed write-up! Would you mind sharing how exactly you sync the PW routes with Vue router?

  • Like 1

Share this post


Link to post
Share on other sites

I just published another update that makes full use of the Add to Home Screen functionality in Chrome (though the app is installable in Android Chrome, Android Firefox, iOS Safari and regular Chrome). In particular, there's a new page that explains how to install the PWA on different devices, with a button that triggers the system dialogue for installing the PWA on supported browsers (only Chrome at the moment). This was a bit difficult to implement since there's little common ground between browsers in this regard at the moment, so let me know if you find any bugs or other errors!

@charger ProcessWire only handles specific paths, everything else is routed to the frontend app and handled by the router. A normal ProcessWire installation uses the .htaccess rules to redirect all requests to it's index.php file. Meanwhile, the Vue SPA uses one single index.html as it's entry point and performs all further routing inside the visitor's browser. Since I want most visitors and paths to go to the frontend app, I modified the .htaccess file ProcessWire comes with to only forward specific routes to ProcessWire, and route all other requests to the Vue app:

# .htaccess

# Requests to the root domain without a path should go to the Vue app (index.html) instead of ProcessWire (index.php)

DirectoryIndex index.html index.php index.htm


# This additional RewriteCond comes right before the main RewriteRule from ProcessWire
# It lets ProcessWire handle only calls to specific paths (in this case, the admin url (/cms), the API endpoint and the sitemap which is generated server-side)

RewriteCond %{REQUEST_URI} (^|/)(api|cms|sitemap)
RewriteRule ^(.*)$ index.php?it=$1 [L,QSA]


# Everything else is routed to index.html, so the Vue app will receive the request and the vue-router can show the appropriate page

# Redirect index.html to /
RewriteRule ^index\.html$ / [L,R=301]

# Redirect everything else to the app
FallbackResource /index.html

 

  • Like 2

Share this post


Link to post
Share on other sites

@schwarzdesign I’m sorry, but I think I wasn’t specific enough. How does your router.js look like? 🙂

As you say the routing is handled by Vue. However, I wonder how Vue knows about the routes that exist. Do you manually add the routes to router.js? Or do you grab existing routes via API and then include them in the Vue router config?

  • Like 1

Share this post


Link to post
Share on other sites

@charger 

If you're looking at the JS in Chrome inspector, you'll see

	  , le = new ue("https://architekturfuehrer.koeln/api/v".concat(ae.API_VERSION, "/"),{
		objects: {
			path: "objects"
		},
		object: {
			path: "objects/{identifier}"
		},
		taxonomies: {
			path: "taxonomies"
		},
		walks: {
			path: "walks"
		},
		pages: {
			path: "pages"
		}
	})
	  , he = new ue("https://architekturfuehrer.koeln/",{
		magazin: {
			path: "magazine/results.json"
		}
	});

These are simply JSON that store all the necessary infos, e.g. https://architekturfuehrer.koeln/api/v1/objects

See the OP under "Backend", 1st paragraph.

  • Like 1

Share this post


Link to post
Share on other sites

@charger Oh I see 🙂  Well, frontend routing is completely handled by Vue, so there are no existing routes (as in, provided by ProcessWire). The only routes ProcessWire knows about are the different API endpoints. One of the screenshots in my post shows the URL segments configuration for the API template. Besides that, @dragan's explanation is spot on; since there are only a handful of API endpoints, those are configured manually. It's not inside the vue-router, since that one is only concerned with routes the visitor sees in their browser; I just wrote a little API class that takes a list of endpoints and made an instance available globally through the Vue prototype. For reference, this is the original unminified code with the endpoint definitions:

export const api = new Connector(
    `https://architekturfuehrer.koeln/api/v${config.API_VERSION}/`,
    {
        objects: {
            path: 'objects',
        },
        object: {
            path: 'objects/{identifier}',
        },
        taxonomies: {
            path: 'taxonomies',
        },
        walks: {
            path: 'walks',
        },
        pages: {
            path: 'pages',
        }
    }
);

As for the frontend routes, they are also configured natively inside the Vue app, here's an excerpt:

import ObjectList from './pages/ObjectList.vue';
import SingleObject from './pages/SingleObject.vue';

export default new VueRouter({
	// ...
  	routes: [
      {
            name: 'objects',
            path: '/objekte/:taxonomy([-_.a-zA-Z0-9]+)?/:term([-_.a-zA-Z0-9]+)?',
            component: ObjectList,
        },
        {
            name: 'object',
            path: '/objekt/:object([-_.a-zA-Z0-9]+)',
            component: SingleObject,
            }
        },
      	// ...
    ],
  	// ...
});

 

Share this post


Link to post
Share on other sites

@schwarzdesign I just noticed that you block /api with your robots.txt: https://architekturfuehrer.koeln/robots.txt

Don't do that. This is most probably the reason Google can't index your Vue-powered site.

You can check what Googlebot really sees here: https://search.google.com/test/mobile-friendly

In your case, it's the empty, unpopulated Vue app tag. Not even the app shell is visible.

When I checked another Vue site earlier this week, Google actually reported issues when confrontend with a bot-blocking robots.txt rule. (another one was a missing cross-origin HTTP header, which doesn't apply for your site, since you're not loading anything from another domain)

  • Like 1

Share this post


Link to post
Share on other sites

@dragan Thanks for the tip, I fixed this alongside with a couple of other issues. The tool was throwing JavaScript errors that I couldn't figure out. For some reason one of my non-Vue components wasn't being transpiled by babel at all, so Google had trouble with the syntax. I have now rewritten the component as a native Vue SFC and added some more polyfills. Now it's finally working for me, and the mobile-friendly checker shows the rendered page correctly ... Hopefully it will improve the indexing situation 🙂

Share this post


Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now

  • Recently Browsing   0 members

    No registered users viewing this page.

  • Similar Content

    • By VeiJari
      Hi, this is the first we are trying to make a page that has only one type of user that has access to every page. 
      The other users should only have a given access to specific pages, not to the whole template.
      My structure
      -Field -Organisation -Project -Report I want that the "measurer" role only has access to "project x" and it's children, but no view access to every project, organisation or field. I've tried to do this with https://modules.processwire.com/modules/page-edit-per-user/ but it still needs a view access to the whole tree to see the "project x" page. Or is there something I haven't figured out?
      Maybe I have to make it via the API: a select field in the "organisation" template where the admins could add the users and then I use hook to update the privileges?
      Have you done something like this and how did you accomplish it?
      Any help would be appreciated.
       
    • By AndZyk
      The ecumenical city pilgrim trail in Villingen, Germany is a small trail through the city where you can visit churches and other places of interest. This Progressive Web App is a small website to guide visitors through these places and give additional informations. You can install it on your smartphone or tablet and walk the trail with it.
      app.stadtpilgerweg-villingen.de
       
      Features:
      Interactive Map Progressive Web App  
      Interactive Map

      Before entering the map you get a little tutorial where you can choose between two routes, the standard trail or a more accessible trail. You can track your position on the map and click on the markers. Each marker is a view with additional information to the place. The views can contain texts, quotes, images or a chat element.
      The map was realized with Leaflet and styled with Mapbox.
       
      Progressive Web App
      The website can be installed as Progressive Web App on your smartphone or tablet for a better experience. The PWA remembers the last visited view and has no unnecessary browser navigation. It can also partly work offline and caches almost everything.
      The PWA was realized with the help of Workbox.
       
      Modules used:
      Repeater Matrix ProCache Map Marker (Google Maps) Sitemap ProcessWire Upgrade TOTP two-factor authentification Tracy Debugger  
      Regards, Andreas
    • By cosmicsafari
      Hi all,
      Before I go potentially wasting time trying to achieve the impossible.
      Can anyone confirm if its possible to have a Page Reference field on a modules config page?
      I'm wanting to essentially just output a list of select able pages based on the a given selector (likely by template at this stage), wherein the select is the pages that the module should apply to etc. I was thinking a simple checkbox list would suffice is asmSelect isn't available.
      Essentially have it display the same way a Page Reference field would display on a template, where you can easily select a bunch of them.
      public function getInputfields() { $inputfields = parent::getInputfields(); $f = $this->modules->get('InputfieldPage'); $f->attr('name', 'testSelect'); $f->setAttribute('multiple', 'checkboxes'); $f->setAttribute('findPagesSelector', 'template=development'); $f->label = 'Test'; $inputfields->add($f); return $inputfields; } Figured something akin to the above would work but can't seem to get rid of this warning on the modules config screen though.

    • By Chris Bennett
      Hi all, I am going round and round in circles and would greatly appreciate if anyone can point me in the right direction.
      I am sure I am doing something dumb, or missing something I should know, but don't. Story of my life 😉

      Playing round with a module and my basic problem is I want to upload an image and also use InputfieldMarkup and other Inputfields.
      Going back and forth between trying an api generated page defining Fieldgroup, Template, Fields, Page and the InputfieldWrapper method.

      InputfieldWrapper method works great for all the markup stuff, but I just can't wrap my head around what I need to do to save the image to the database.
      Can generate a Field for it (thanks to the api investigations) but not sure what I need to do to link the Inputfield to that. Tried a lot of stuff from various threads, of varying dates without luck.
      Undoubtedly not helped by me not knowing enough.

      Defining Fieldgroup etc through the api seems nice and clean and works great for the images but I can't wrap my head around how/if I can add/append/hook the InputfieldWrapper/InputfieldMarkup stuff I'd like to include on that template as well. Not even sure if it should be where it is on ___install with the Fieldtype stuff or later on . Not getting Tracy errors, just nothing seems to happen.
      If anyone has any ideas or can point me in the right direction, that would be great because at the moment I am stumbling round in the dark.
       
      public function ___install() { parent::___install(); $page = $this->pages->get('name='.self::PAGE_NAME); if (!$page->id) { // Create fieldgroup, template, fields and page // Create new fieldgroup $fmFieldgroup = new Fieldgroup(); $fmFieldgroup->name = MODULE_NAME.'-fieldgroup'; $fmFieldgroup->add($this->fields->get('title')); // needed title field $fmFieldgroup->save(); // Create new template using the fieldgroup $fmTemplate = new Template(); $fmTemplate->name = MODULE_NAME; $fmTemplate->fieldgroup = $fmFieldgroup; $fmTemplate->noSettings = 1; $fmTemplate->noChildren = 1; $fmTemplate->allowNewPages = 0; $fmTemplate->tabContent = MODULE_NAME; $fmTemplate->noChangeTemplate = 1; $fmTemplate->setIcon(ICON); $fmTemplate->save(); // Favicon source $fmField = new Field(); $fmField->type = $this->modules->get("FieldtypeImage"); $fmField->name = 'fmFavicon'; $fmField->label = 'Favicon'; $fmField->focusMode = 'off'; $fmField->gridMode = 'grid'; $fmField->extensions = 'svg png'; $fmField->columnWidth = 50; $fmField->collapsed = Inputfield::collapsedNever; $fmField->setIcon(ICON); $fmField->addTag(MODULE_NAME); $fmField->save(); $fmFieldgroup->add($fmField); // Favicon Silhouette source $fmField = new Field(); $fmField->type = $this->modules->get("FieldtypeImage"); $fmField->name = 'fmFaviconSilhouette'; $fmField->label = 'SVG Silhouette'; $fmField->notes = 'When creating a silhouette/mask svg version for Safari Pinned Tabs and Windows Tiles, we recommend setting your viewbox for 0 0 16 16, as this is what Apple requires. In many cases, the easiest way to do this in something like illustrator is a sacrificial rectangle with no fill, and no stroke at 16 x 16. This forces the desired viewbox and can then be discarded easily using something as simple as notepad. Easy is good, especially when you get the result you want without a lot of hassle.'; $fmField->focusMode = 'off'; $fmField->extensions = 'svg'; $fmField->columnWidth = 50; $fmField->collapsed = Inputfield::collapsedNever; $fmField->setIcon(ICON); $fmField->addTag(MODULE_NAME); $fmField->save(); $fmFieldgroup->add($fmField); // Create: Open Settings Tab $tabOpener = new Field(); $tabOpener->type = new FieldtypeFieldsetTabOpen(); $tabOpener->name = 'fmTab1'; $tabOpener->label = "Favicon Settings"; $tabOpener->collapsed = Inputfield::collapsedNever; $tabOpener->addTag(MODULE_NAME); $tabOpener->save(); // Create: Close Settings Tab $tabCloser = new Field(); $tabCloser->type = new FieldtypeFieldsetClose; $tabCloser->name = 'fmTab1' . FieldtypeFieldsetTabOpen::fieldsetCloseIdentifier; $tabCloser->label = "Close open tab"; $tabCloser->addTag(MODULE_NAME); $tabCloser->save(); // Create: Opens wrapper for Favicon Folder Name $filesOpener = new Field(); $filesOpener->type = new FieldtypeFieldsetOpen(); $filesOpener->name = 'fmOpenFolderName'; $filesOpener->label = 'Wrap Folder Name'; $filesOpener->class = 'inline'; $filesOpener->collapsed = Inputfield::collapsedNever; $filesOpener->addTag(MODULE_NAME); $filesOpener->save(); // Create: Close wrapper for Favicon Folder Name $filesCloser = new Field(); $filesCloser->type = new FieldtypeFieldsetClose(); $filesCloser->name = 'fmOpenFolderName' . FieldtypeFieldsetOpen::fieldsetCloseIdentifier; $filesCloser->label = "Close open fieldset"; $filesCloser->addTag(MODULE_NAME); $filesCloser->save(); // Create Favicon Folder Name $fmField = new Field(); $fmField->type = $this->modules->get("FieldtypeText"); $fmField->name = 'folderName'; $fmField->label = 'Favicon Folder:'; $fmField->description = $this->config->urls->files; $fmField->placeholder = 'Destination Folder for your generated favicons, webmanifest and browserconfig'; $fmField->columnWidth = 100; $fmField->collapsed = Inputfield::collapsedNever; $fmField->setIcon('folder'); $fmField->addTag(MODULE_NAME); $fmField->save(); $fmFieldgroup->add($tabOpener); $fmFieldgroup->add($filesOpener); $fmFieldgroup->add($fmField); $fmFieldgroup->add($filesCloser); $fmFieldgroup->add($tabCloser); $fmFieldgroup->save(); /////////////////////////////////////////////////////////////// // Experimental Markup Tests $wrapperFaviconMagic = new InputfieldWrapper(); $wrapperFaviconMagic->attr('id','faviconMagicWrapper'); $wrapperFaviconMagic->attr('title',$this->_('Favicon Magic')); // field show info what $field = $this->modules->get('InputfieldMarkup'); $field->name = 'use'; $field->label = __('How do I use it?'); $field->collapsed = Inputfield::collapsedNever; $field->icon('info'); $field->attr('value', 'Does this even begin to vaguely work?'); $field->columnWidth = 50; $wrapperFaviconMagic->add($field); $fmTemplate->fields->add($wrapperFaviconMagic); $fmTemplate->fields->save(); ///////////////////////////////////////////////////////////// // Create page $page = $this->wire( new Page() ); $page->template = MODULE_NAME; $page->parent = $this->wire('pages')->get('/'); $page->addStatus(Page::statusHidden); $page->title = 'Favicons'; $page->name = self::PAGE_NAME; $page->process = $this; $page->save(); } }  
×
×
  • Create New...