diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..f625c9d2 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu + +# Node v22 setup - refer to below URL for details +# https://github.com/nodesource/distributions?tab=readme-ov-file#using-ubuntu-nodejs-20 +RUN apt-get install -y curl \ + && curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh \ + && bash nodesource_setup.sh \ + && apt-get install -y nodejs python3-setuptools \ + && node -v # verify Node installation + +# Ghost setup +RUN npm install ghost-cli@latest -g +USER vscode +RUN mkdir ~/ghost \ + && cd ~/ghost \ + && ghost install local diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..987389ca --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu +{ + "name": "Ubuntu 24.04 w/ Ghost", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "build": { "dockerfile": "Dockerfile" }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [2368], + + "containerEnv": { + "REPO": "/workspaces/dawn-advisory-theme" + }, + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "$REPO/.devcontainer/ghost_setup.sh", + "postStartCommand": "npm run prepare && cd ~/ghost && ghost start", + "postAttachCommand": "npm run dev" + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/ghost_setup.sh b/.devcontainer/ghost_setup.sh new file mode 100755 index 00000000..6481e912 --- /dev/null +++ b/.devcontainer/ghost_setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +export REPO="/workspaces/dawn-advisory-theme" + +# Step 1: Create symbolic link to theme +ln -s $REPO ~/ghost/content/themes + +# Step 2: Start Ghost +cd ~/ghost +ghost start + +# Step 3: Install dependencies + run setup script +cd $REPO/.devcontainer/ghost_setup +npm install && npm start diff --git a/.devcontainer/ghost_setup/index.js b/.devcontainer/ghost_setup/index.js new file mode 100644 index 00000000..56efb113 --- /dev/null +++ b/.devcontainer/ghost_setup/index.js @@ -0,0 +1,52 @@ +const readline = require("readline"); +const GhostAdminAPI = require("@tryghost/admin-api"); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: "> ", +}); + +console.log("---"); +console.log("Please do the following steps:"); +console.log("Step 1: Complete the setup at http://localhost:2368/ghost/."); +console.log( + "The actual details don't matter, but make sure to save your username and password.", +); +console.log( + "Step 2: Follow https://ghost.org/docs/admin-api/#token-authentication to create an integration.", +); +console.log("Step 3: Copy the Admin API key into the following prompt."); +console.log("---"); + +(async () => { + const apiKey = await new Promise((callback) => { + rl.question("Admin API Key: ", callback); + }); + + const api = new GhostAdminAPI({ + url: "http://localhost:2368", + version: "v5.0", + key: apiKey, + }); + + console.log("Activating repository theme..."); + await api.themes.activate("dawn-advisory-theme").then((res) => { + console.log(JSON.stringify(res)); + }); + console.log("Finished setup!"); + + console.log("---"); + console.log("The remaining steps need to be done manually:"); + console.log( + "1. Under Settings > Advanced > Import/Export, import an export of the main Ghost website.", + ); + console.log("2. Under Settings > Labs, import redirects and routes."); + console.log("For more details, please reach out to the maintainers! :)"); + console.log("---"); + + await new Promise((callback) => { + rl.question("Press enter to continue...", callback); + }); + rl.close(); +})(); diff --git a/.devcontainer/ghost_setup/package.json b/.devcontainer/ghost_setup/package.json new file mode 100644 index 00000000..a150f2b2 --- /dev/null +++ b/.devcontainer/ghost_setup/package.json @@ -0,0 +1,14 @@ +{ + "name": "ghost_setup", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "author": "", + "license": "MIT", + "description": "", + "dependencies": { + "@tryghost/admin-api": "^1.14.0" + } +} diff --git a/.djlintrc b/.djlintrc new file mode 100644 index 00000000..36ed4d6b --- /dev/null +++ b/.djlintrc @@ -0,0 +1,5 @@ +{ + "profile": "handlebars", + "extension": "hbs", + "ignore": "H006,H013,H017,H021,H030,H031" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2e22d271..ff2770ef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,27 @@ version: 2 updates: - - package-ecosystem: npm + - package-ecosystem: "npm" directory: "/" schedule: - interval: weekly - reviewers: - - "AdvisorySG/ghost-reviewers" - open-pull-requests-limit: 4 + interval: "weekly" + groups: + production-dependencies: + dependency-type: "production" + development-dependencies: + dependency-type: "development" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + all-dependencies: + patterns: ["*"] + + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "weekly" + groups: + all-dependencies: + patterns: ["*"] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 870a5fda..f6adc5ef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,23 +3,37 @@ on: pull_request: push: jobs: + pre_build: + continue-on-error: true + runs-on: ubuntu-24.04 + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v4 + with: + concurrent_skipping: "same_content_newer" + paths_ignore: '["CONTRIBUTING.md", "LICENSE", "**/README.md"]' + build: + needs: pre_build + if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || needs.pre_build.outputs.should_skip != 'true' }} runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x] + node-version: [22.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Cache Node modules - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: Setup Node - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} @@ -30,7 +44,7 @@ jobs: - run: unzip dist/dawn-advisory-theme.zip -d dist/ - run: rm dist/dawn-advisory-theme.zip - name: Upload build - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: dawn-advisory-theme-${{ github.sha }} path: dist/ @@ -41,19 +55,19 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x] + node-version: [22.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Cache Node modules - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: Setup Node - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} @@ -61,7 +75,7 @@ jobs: - run: npm run zip - name: Deploy Ghost theme - uses: TryGhost/action-deploy-theme@v1.4.1 + uses: TryGhost/action-deploy-theme@v1.6.6 with: api-url: ${{ secrets.GHOST_API_URL }} api-key: ${{ secrets.GHOST_DEPLOY_THEME_ADMIN_API_KEY }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6c4fb05e..ea16f355 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,10 +4,10 @@ on: push: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: - node-version: [16.x] + node-version: [22.x] steps: - uses: actions/checkout@v2 @@ -22,7 +22,14 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 - run: npm install + - run: pip install djlint + - run: npx eslint . - run: npx prettier -c . + - run: djlint . --lint diff --git a/.gitignore b/.gitignore index d4eedcc0..e2539788 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ dist/ config.json changelog.md changelog.md.bk + +replit.nix +.replit \ No newline at end of file diff --git a/README.md b/README.md index 7166f25a..0ac9159e 100644 --- a/README.md +++ b/README.md @@ -22,29 +22,12 @@ npm run zip 4. Upload the zipfile at `dist/dawn-advisory-theme.zip` onto your local Ghost instance at `Settings > Theme > Change theme > Upload theme`. -Optionally, if you have access to the [Admin panel of Advisory](https://beta.advisory.sg/ghost/), you can go to `Settings > Labs > Migration Options > Export your content` in order to export the posts and settings used for the actual website as a JSON file. This file can be imported into your local instance of Ghost, at `Settings > Labs > Migration Options > Import content`. Take note that this will not remove existing posts/pages. +For new developers, please ask Tech Management for the posts and settings to be exported, and to pass you the output JSON file. +This file can be imported into your local instance of Ghost, at `Settings > Labs > Migration Options > Import content`. Take note that this will not remove existing posts/pages. -# Search +Optionally, if you have access to the [Admin panel of Advisory](https://beta.advisory.sg/ghost/), you can go to `Settings > Labs > Migration Options > Export your content` in order to export the posts and settings used for the actual website as a JSON file. -1. Navigate to the `Integrations` and click on `Add custom integration`. -2. Copy the content API key; this will be used to fetch posts from your site. -3. Insert the generated key in `Settings > Design > Site-wide > Content API key for search". - -The theme generates an index of posts for highly performant search. The index is updated automatically when posts are added or updated. However, it isn't updated when posts are unpublished or deleted. - -To force update the index, increment the search index migration version like `'v2'`. - -## Disable Content Search - -When your site has lots of posts, including the post content in the index cache ends up with exceeding the browser local storage quota. In that case, disabling content search is recommended. Also make sure increase the migration version to force update the old index. - -```html - -``` +Furthermore, also make sure to setup `routes.yaml` as explained further down below # Dropdown Menu @@ -88,6 +71,24 @@ For the homepage and separate [Stories](https://beta.advisory.sg/stories) page t **Note**: The `routes.yaml` file supplied in the repository is not automatically deployed onto the main website. +# Typesense Search + +The `/events/` and `/interviews/` pages have a typo-tolerant search box backed by [Typesense](https://typesense.org/), and the bottom-of-article "you might also like" widget uses Typesense for content-relevance ranking. Three settings live under `config.custom` in `package.json` and are admin-overridable in **Ghost Admin -> Settings -> Design -> Customize**: + +| Setting | Default | What it is | +| ---------------------- | ------------------------------- | ------------------------------------------------------------------ | +| `typesense_host` | `https://typesense.advisory.sg` | Typesense host URL (no trailing slash). HTTPS recommended. | +| `typesense_api_key` | (Advisory SG search-only key) | Typesense **search-only** API key. Embedded client-side by design. | +| `typesense_collection` | `ghost` | Name of the indexed Ghost-posts collection on the Typesense host. | + +The flow is: `package.json` defaults → `default.hbs` injects them as `window.__TYPESENSE_CONFIG__` → `assets/js/typesense-search.js` reads from that global at search time, falling back to the same defaults if the global is missing. + +**About the API key in source.** Typesense splits keys into _admin_ (read/write, secret) and _search-only_ (read-only, scoped to a collection). The search-only key is the analogue of Ghost's Content API key — it's designed to ship in client-side JavaScript and only grants read access to data that's already public. Don't paste an admin key here. + +**Deploying to a different Ghost instance.** If your instance points at a different Typesense backend, override the three settings in **Design → Customize** rather than editing the theme. The defaults in `package.json` are only the fallback for installs that don't override. + +**About the indexer.** This theme expects an existing Typesense collection populated with Ghost posts (`title`, `slug`, `excerpt`, `plaintext`, `feature_image`, `url`, `tags.name`, `tags.slug`, `published_at`, etc.). The sync mechanism (e.g. [MagicPages' Ghost-Typesense integration](https://github.com/magicpages/ghost-typesense)) is **not** part of this theme. + # PostCSS Features Used - Autoprefixer - Don't worry about writing browser prefixes of any kind, it's all done automatically with support for the latest 2 major versions of every browser. diff --git a/assets/css/blog/featured.css b/assets/css/blog/featured.css index f99e62d9..55e2359b 100644 --- a/assets/css/blog/featured.css +++ b/assets/css/blog/featured.css @@ -1,23 +1,23 @@ .featured-wrapper { - margin-top: 6rem; -} - -.featured-wrapper .u-placeholder { - margin-bottom: 3rem; + margin-bottom: 6rem; } .featured-wrapper .post-title { margin-bottom: 0; - font-size: 1.8rem; + font-size: 3.2rem; font-weight: 700; } +.featured-track { + align-items: center; +} + .has-serif-title .featured-wrapper .post-title { font-family: var(--font-serif); } .featured-title { - margin-bottom: 4.5rem; + margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--light-gray-color); color: var(--brand-color); @@ -25,3 +25,30 @@ letter-spacing: 0.05rem; text-transform: uppercase; } + +.slide-content { + display: flex; + align-items: center; +} + +.slide-image { + width: 50%; + flex-shrink: 0; + margin-right: 20px; + margin-top: 20px; + margin-left: 20px; +} + +@media (max-width: 767px) { + .slide-image { + width: 100%; + margin-top: 0px; + margin-bottom: 0px; + } +} + +@media (min-width: 768px) { + .featured-wrapper .u-placeholder { + margin-bottom: 3rem; + } +} diff --git a/assets/css/blog/navigation.css b/assets/css/blog/navigation.css index 4d9003d9..73858342 100644 --- a/assets/css/blog/navigation.css +++ b/assets/css/blog/navigation.css @@ -16,3 +16,19 @@ width: 30px; height: 30px; } + +.navigation .navigation-icon { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +.navigation { + position: relative; + height: 0; + width: 43px; + padding: 0; + padding-bottom: 43px; +} diff --git a/assets/css/blog/quotes.css b/assets/css/blog/quotes.css new file mode 100644 index 00000000..65b62e6e --- /dev/null +++ b/assets/css/blog/quotes.css @@ -0,0 +1,6 @@ +.glide__bullet { + background-color: rgba(0, 0, 0, 0.26); +} +.glide__bullet--active { + background-color: rgb(0 0 0); +} diff --git a/assets/css/blog/share.css b/assets/css/blog/share.css index 2e0ae356..c781da25 100644 --- a/assets/css/blog/share.css +++ b/assets/css/blog/share.css @@ -23,11 +23,10 @@ } .share-link .icon-facebook { - margin-right: 0.3rem; margin-left: -0.3rem; } -.share-link .icon-twitter { +.share-link .icon-x { margin-right: 0.3rem; margin-left: -0.3rem; } @@ -41,8 +40,8 @@ background-color: var(--facebook-color); } -.share-link-twitter { - background-color: var(--twitter-color); +.share-link-x { + background-color: var(--x-color); } .share-link-linkedin { diff --git a/assets/css/blog/single.css b/assets/css/blog/single.css index a8930def..5d3e928e 100644 --- a/assets/css/blog/single.css +++ b/assets/css/blog/single.css @@ -4,11 +4,23 @@ } .single-meta { - margin-bottom: 1rem; + margin-bottom: 2rem; color: var(--secondary-text-color); - font-size: 1.2rem; + font-size: 1.5rem; + line-height: 2rem; font-weight: 800; text-transform: uppercase; + padding: 1.5rem; + +} + +.single-meta-date { + font-size: 1.5rem; + +} + +.single-meta-length { + font-size: 1.5rem; } .single-meta-item + .single-meta-item::before { @@ -19,11 +31,13 @@ .single-meta-tag .post-tag { color: var(--brand-color); + position: relative; + z-index: 2; } .single-title { margin-bottom: 0; - font-size: 4rem; + font-size: 3.75rem; line-height: 1.2; letter-spacing: -0.1rem; } @@ -42,7 +56,6 @@ } .single-media { - margin-top: 3rem; margin-bottom: 3rem; } @@ -101,10 +114,6 @@ font-size: 1.8rem; } -.single-footer-bottom { - margin-top: 2rem; -} - @media (max-width: 767px) { .single-header { margin-bottom: 1.5rem; @@ -122,3 +131,12 @@ margin-top: 3rem; } } + +.stretch-link { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 1; +} diff --git a/assets/css/blog/tag.css b/assets/css/blog/tag.css index b2f4f3cd..f279f823 100644 --- a/assets/css/blog/tag.css +++ b/assets/css/blog/tag.css @@ -14,28 +14,11 @@ max-width: 500px; } -.tag-list { - margin-top: 2rem; - font-weight: 700; -} - -.tag-list-label { - color: var(--secondary-text-color); -} - -.tag-list-item:not(:last-child):after { - content: ", "; -} - -.tag-list-item { - color: var(--brand-color); -} - -.tag-media { +.tag-feature-media { margin-top: 4.5rem; } -.tag-media::before { +.tag-feature-media::before { padding-bottom: 35% !important; } @@ -44,7 +27,7 @@ margin-bottom: 2.5rem; } - .tag-media { + .tag-feature-media { margin-top: 3rem; } } diff --git a/assets/css/blog/team.css b/assets/css/blog/team.css new file mode 100644 index 00000000..8853585c --- /dev/null +++ b/assets/css/blog/team.css @@ -0,0 +1,31 @@ +.parent { + text-align: center; +} + +.gallery { + margin-left: auto; + margin-right: auto; + border: 0px; + width: 240px; + display: inline-block; +} + +.gallery img { + width: 100%; + height: auto; + border-radius: 50%; + display: inline-block; +} + +.desc { + padding: 15px; + text-align: center; + font-weight: normal; +} + +.desc::first-line { + font-weight: bold; +} +.page-header { + text-align: center; +} diff --git a/assets/css/general/basics.css b/assets/css/general/basics.css index f1f2073e..94e07ab3 100644 --- a/assets/css/general/basics.css +++ b/assets/css/general/basics.css @@ -13,7 +13,7 @@ --orange-color: #ffc107; --red-color: #dc3545; --facebook-color: #3b5998; - --twitter-color: #1da1f2; + --x-color: #000; --linkedin-color: #0288d1; --animation-base: ease-in-out; --font-base: Mulish, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, @@ -35,7 +35,13 @@ html { box-sizing: inherit; } +footer { + background: var(--black-color); + color: var(--white-color); +} + body { + background: var(--black-color); color: var(--primary-text-color); font-family: var(--font-base); font-size: 1.5rem; diff --git a/assets/css/misc/kg.css b/assets/css/misc/kg.css index 395fdb56..bb8260ce 100644 --- a/assets/css/misc/kg.css +++ b/assets/css/misc/kg.css @@ -1,6 +1,22 @@ +.kg-gallery-card, +.kg-gallery-card * { + box-sizing: border-box; +} + +.kg-gallery-card, +.kg-image-card { + --gap: 1.2rem; +} + +.kg-image-card:not(.kg-card-hascaption) + .kg-image-card, +.kg-image-card:not(.kg-card-hascaption) + .kg-gallery-card, +.kg-gallery-card:not(.kg-card-hascaption) + .kg-image-card, +.kg-gallery-card:not(.kg-card-hascaption) + .kg-gallery-card { + margin-top: var(--gap); +} + .kg-gallery-container { - display: flex; - flex-direction: column; + position: relative; } .kg-gallery-row { @@ -9,21 +25,26 @@ justify-content: center; } -.kg-gallery-image { - cursor: pointer; -} - .kg-gallery-image img { + display: block; + margin: 0; width: 100%; height: 100%; } .kg-gallery-row:not(:first-of-type) { - margin: 10px 0 0; + margin: var(--gap) 0 0; } .kg-gallery-image:not(:first-of-type) { - margin: 0 0 0 10px; + margin: 0 0 0 var(--gap); +} + +@media (max-width: 600px) { + .kg-gallery-card, + .kg-image-card { + --gap: 0.6rem; + } } .kg-bookmark-card { diff --git a/assets/css/misc/utils.css b/assets/css/misc/utils.css index 0ad56db9..733fc2ad 100644 --- a/assets/css/misc/utils.css +++ b/assets/css/misc/utils.css @@ -8,7 +8,7 @@ } .u-text-format > * + * { - margin-top: 3rem; + margin-top: 1rem; } .u-text-format > *:first-child { @@ -22,7 +22,7 @@ } .u-text-format > [id] { - margin-top: 4rem; + margin-top: 2rem; } .u-text-format > [id] + p { @@ -155,3 +155,7 @@ height: 100%; object-fit: cover; } + +[x-cloak] { + display: none !important; +} diff --git a/assets/css/screen.css b/assets/css/screen.css index 8aaf3a0a..db1a0aea 100644 --- a/assets/css/screen.css +++ b/assets/css/screen.css @@ -16,6 +16,8 @@ @import "site/modal.css"; @import "site/search.css"; @import "site/burger.css"; +@import "site/dropdown.css"; +@import "site/footnotes.css"; @import "blog/feed.css"; @import "blog/featured.css"; @import "blog/pagination.css"; @@ -26,17 +28,18 @@ @import "blog/related.css"; @import "blog/comment.css"; @import "blog/tag.css"; +@import "blog/quotes.css"; @import "members/plan.css"; @import "members/auth.css"; @import "members/account.css"; @import "members/notification.css"; -@import "vendor/owl.css"; -@import "vendor/pswp.css"; @import "misc/lazyload.css"; @import "misc/kg.css"; @import "misc/utils.css"; @import "misc/animations.css"; +@import "../../node_modules/@glidejs/glide/dist/css/glide.core.min.css"; + @layer base { ul { list-style: disc; diff --git a/assets/css/site/dropdown.css b/assets/css/site/dropdown.css new file mode 100644 index 00000000..8b2218d6 --- /dev/null +++ b/assets/css/site/dropdown.css @@ -0,0 +1,13 @@ +.dropdown-content { + padding: 10px; + margin: 5px 0; +} + +.dropdown-header { + cursor: pointer; + padding: 10px; +} + +.arrow-icon { + padding-right: 10px; +} diff --git a/assets/css/site/footer.css b/assets/css/site/footer.css index f5db43fe..1fd02753 100644 --- a/assets/css/site/footer.css +++ b/assets/css/site/footer.css @@ -72,3 +72,7 @@ .theme-text { margin-left: 0.3rem; } + +.footer-text { + color: white; +} diff --git a/assets/css/site/footnotes.css b/assets/css/site/footnotes.css new file mode 100644 index 00000000..5ff6d5b2 --- /dev/null +++ b/assets/css/site/footnotes.css @@ -0,0 +1,35 @@ +hr.footnotes-sep { + display: none; +} + +section.footnotes ol { + list-style: none; + counter-reset: footnote-counter; +} + +section.footnotes ol li { + counter-increment: footnote-counter; + position: relative; +} + +section.footnotes ol li::before { + content: "[" counter(footnote-counter) "]"; + font-weight: 700; + position: absolute; + left: -1.2em; + color: var(--brand-color); + font-weight: bold; +} + +section.footnotes p { + padding-left: 0.5em; +} + +section.footnotes a { + color: var(--brand-color); + text-decoration: none; +} + +section.footnotes a:hover { + text-decoration: underline; +} diff --git a/assets/css/site/header.css b/assets/css/site/header.css index 06278ef1..78f288a5 100644 --- a/assets/css/site/header.css +++ b/assets/css/site/header.css @@ -52,6 +52,7 @@ .menu-item { margin: 0 1.5rem; font-weight: 700; + white-space: nowrap; } .menu-item[href*="..."], diff --git a/assets/css/site/layout.css b/assets/css/site/layout.css index 2902a8a4..8a1f6187 100644 --- a/assets/css/site/layout.css +++ b/assets/css/site/layout.css @@ -6,11 +6,4 @@ .site-content { flex-grow: 1; - padding: 6rem 0; -} - -@media (max-width: 767px) { - .site-content { - padding: 3rem 0; - } } diff --git a/assets/css/site/modal.css b/assets/css/site/modal.css index 3de1afb1..2f7185d0 100644 --- a/assets/css/site/modal.css +++ b/assets/css/site/modal.css @@ -23,7 +23,9 @@ } .modal-search { - width: 500px; + width: 60%; + height: 80%; + position: relative; } .modal-search .form-wrapper { diff --git a/assets/css/site/search.css b/assets/css/site/search.css index ece7f521..2b685e1a 100644 --- a/assets/css/site/search.css +++ b/assets/css/site/search.css @@ -16,23 +16,41 @@ } .search-result { - overflow-y: scroll; - max-height: 50vh; -webkit-overflow-scrolling: touch; + max-height: 70vh; } .search-result-row + .search-result-row { border-top: 1px solid var(--light-gray-color); } +.search-result-text { + font-size: 1.5rem; +} + .search-result-row-link { display: block; padding: 1.3rem 1.5rem 1.2rem; - font-size: 1.5rem; + font-size: 1.8rem; line-height: 1.4; + height: 100%; + overflow: hidden; } -.search-result-row-link:hover { +.search-result-row-link:hover, +.search-result-row-link:focus { background-color: var(--lighter-gray-color); opacity: 1; } + +.search-prev:disabled { + background: #f5f5f5; + color: #c3c3c3; + outline: auto; +} + +.search-next:disabled { + background: #f5f5f5; + color: #c3c3c3; + outline: auto; +} diff --git a/assets/css/site/sticky.css b/assets/css/site/sticky.css index 0f7fa913..817021bb 100644 --- a/assets/css/site/sticky.css +++ b/assets/css/site/sticky.css @@ -32,7 +32,7 @@ left: 0; width: 100%; height: 2px; - background-color: var(--light-gray-color); + background-color: var(--brand-color); } .sticky-progress { @@ -48,7 +48,7 @@ @media (max-width: 767px) { .sticky-title { - font-size: 1.4rem; + font-size: 1.7rem; font-weight: 700; } } diff --git a/assets/css/vendor/owl.css b/assets/css/vendor/owl.css deleted file mode 100644 index d6797f0d..00000000 --- a/assets/css/vendor/owl.css +++ /dev/null @@ -1,151 +0,0 @@ -.owl { - display: none; - position: relative; - -webkit-tap-highlight-color: transparent; - width: 100%; - z-index: 1; -} - -.owl .owl-stage { - position: relative; - touch-action: pan-y; -} - -.owl .owl-stage::after { - clear: both; - content: "."; - display: block; - height: 0; - line-height: 0; - visibility: hidden; -} - -.owl .owl-stage-outer { - overflow: hidden; - position: relative; - transform: translate3d(0, 0, 0); -} - -.owl .owl-item { - backface-visibility: hidden; - float: left; - min-height: 1px; - position: relative; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; - transform: translateZ(0); -} - -.owl .owl-item > img { - display: block; - transform-style: preserve-3d; - width: 100%; -} - -.owl .owl-nav.disabled, -.owl .owl-dots.disabled { - display: none; -} - -.owl .owl-prev, -.owl .owl-next, -.owl .owl-dot { - cursor: pointer; - user-select: none; -} - -.owl .owl-prev, -.owl .owl-next { - align-items: center; - background-color: var(--white-color); - border: 1px solid var(--light-gray-color); - border-radius: 3px; - color: var(--dark-gray-color); - display: flex; - height: 30px; - justify-content: center; - outline: none; - padding: 0; - position: absolute; - text-align: center; - top: -86px; - transition: color 0.5s var(--animation-base); - width: 30px; -} - -.owl .owl-prev.disabled, -.owl .owl-next.disabled { - color: var(--mid-gray-color); - cursor: default; -} - -.owl .owl-prev .icon, -.owl .owl-next .icon { - height: 18px; - width: 18px; -} - -.owl .owl-prev { - right: 34px; -} - -.owl .owl-next { - right: 0; -} - -.owl .owl-dots { - display: flex; - justify-content: center; - margin-top: 20px; -} - -.owl .owl-dot { - align-items: center; - border: 0; - display: flex; - height: 20px; - justify-content: center; - outline: none; - padding: 0; - width: 20px; -} - -.owl .owl-dot span { - background-color: var(--light-gray-color); - border-radius: 50%; - height: 8px; - width: 8px; -} - -.owl .owl-dot.active span { - background-color: var(--black-color); -} - -.owl.owl-loaded { - display: block; -} - -.owl.owl-loading { - display: block; - opacity: 0; -} - -.owl.owl-hidden { - opacity: 0; -} - -.owl.owl-refresh .owl-item { - visibility: hidden; -} - -.owl.owl-drag .owl-item { - user-select: none; -} - -.owl.owl-grab { - cursor: move; -} - -.no-js .owl { - display: block; -} diff --git a/assets/css/vendor/pswp.css b/assets/css/vendor/pswp.css deleted file mode 100644 index 08801dfc..00000000 --- a/assets/css/vendor/pswp.css +++ /dev/null @@ -1,420 +0,0 @@ -.pswp { - backface-visibility: hidden; - display: none; - height: 100%; - left: 0; - position: absolute; - outline: none; - overflow: hidden; - top: 0; - touch-action: none; - width: 100%; - z-index: 1500; - -ms-touch-action: none; - -webkit-text-size-adjust: 100%; -} -.pswp img { - max-width: none; -} -.pswp--animate_opacity { - opacity: 0.001; - transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); - will-change: opacity; -} -.pswp--open { - display: block; -} -.pswp--zoom-allowed .pswp__img { - cursor: zoom-in; -} -.pswp--zoomed-in .pswp__img { - cursor: grab; -} -.pswp--dragging .pswp__img { - cursor: grabbing; -} -.pswp__bg { - backface-visibility: hidden; - background-color: rgba(0, 0, 0, 0.85); - height: 100%; - left: 0; - opacity: 0; - position: absolute; - top: 0; - transform: translateZ(0); - transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); - width: 100%; - will-change: opacity; -} -.pswp__scroll-wrap { - height: 100%; - left: 0; - overflow: hidden; - position: absolute; - top: 0; - width: 100%; -} -.pswp__container, -.pswp__zoom-wrap { - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - touch-action: none; -} -.pswp__container, -.pswp__img { - user-select: none; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; -} -.pswp__zoom-wrap { - position: absolute; - transform-origin: left top; - transition: transform 333ms cubic-bezier(0.4, 0, 0.22, 1); - width: 100%; -} -.pswp--animated-in .pswp__bg, -.pswp--animated-in .pswp__zoom-wrap { - transition: none; -} -.pswp__container, -.pswp__zoom-wrap { - backface-visibility: hidden; -} -.pswp__item { - bottom: 0; - left: 0; - overflow: hidden; - position: absolute; - right: 0; - top: 0; -} -.pswp__img { - height: auto; - left: 0; - position: absolute; - top: 0; - width: auto; -} -.pswp__img--placeholder { - backface-visibility: hidden; -} -.pswp__img--placeholder--blank { - background: var(--black-color); -} -.pswp--ie .pswp__img { - height: auto !important; - left: 0; - top: 0; - width: 100% !important; -} -.pswp__error-msg { - color: var(--secondary-text-color); - font-size: 14px; - left: 0; - line-height: 16px; - margin-top: -8px; - position: absolute; - text-align: center; - top: 50%; - width: 100%; -} -.pswp__error-msg a { - color: var(--secondary-text-color); - text-decoration: underline; -} -.pswp__button { - appearance: none; - background: none; - border: 0; - box-shadow: none; - cursor: pointer; - display: block; - float: right; - height: 44px; - margin: 0; - overflow: visible; - padding: 0; - position: relative; - transition: opacity 0.2s; - width: 44px; -} -.pswp__button:focus, -.pswp__button:hover { - opacity: 1; -} -.pswp__button:active { - opacity: 0.9; - outline: none; -} -.pswp__button::-moz-focus-inner { - border: 0; - padding: 0; -} -.pswp__ui--over-close .pswp__button--close { - opacity: 1; -} -.pswp__button, -.pswp__button--arrow--left:before, -.pswp__button--arrow--right:before { - background: url(../images/default-skin.png) 0 0 no-repeat; - background-size: 264px 88px; - height: 44px; - width: 44px; -} -@media (-webkit-min-device-pixel-ratio: 1.1), (-webkit-min-device-pixel-ratio: 1.09375), (min-resolution: 105dpi), (min-resolution: 1.1dppx) { - .pswp--svg .pswp__button, - .pswp--svg .pswp__button--arrow--left:before, - .pswp--svg .pswp__button--arrow--right:before { - background-image: url(../images/default-skin.svg); - } - .pswp--svg .pswp__button--arrow--left, - .pswp--svg .pswp__button--arrow--right { - background: none; - } -} -.pswp__button--close { - background-position: 0 -44px; -} -.pswp__button--share { - background-position: -44px -44px; -} -.pswp__button--fs { - display: none; -} -.pswp--supports-fs .pswp__button--fs { - display: block; -} -.pswp--fs .pswp__button--fs { - background-position: -44px 0; -} -.pswp__button--zoom { - background-position: -88px 0; - display: none; -} -.pswp--zoom-allowed .pswp__button--zoom { - display: block; -} -.pswp--zoomed-in .pswp__button--zoom { - background-position: -132px 0; -} -.pswp--touch .pswp__button--arrow--left, -.pswp--touch .pswp__button--arrow--right { - visibility: hidden; -} -.pswp__button--arrow--left, -.pswp__button--arrow--right { - background: none; - height: 100px; - margin-top: -50px; - position: absolute; - top: 50%; - width: 70px; -} -.pswp__button--arrow--left { - left: 0; -} -.pswp__button--arrow--right { - right: 0; -} -.pswp__button--arrow--left:before, -.pswp__button--arrow--right:before { - content: ""; - height: 30px; - position: absolute; - top: 35px; - width: 32px; -} -.pswp__button--arrow--left:before { - background-position: -138px -44px; - left: 6px; -} -.pswp__button--arrow--right:before { - background-position: -94px -44px; - right: 6px; -} -.pswp__counter { - color: var(--white-color); - font-size: 11px; - font-weight: 700; - height: 44px; - left: 0; - line-height: 44px; - padding: 0 15px; - position: absolute; - top: 0; - user-select: none; -} -.pswp__caption { - bottom: 0; - left: 0; - min-height: 44px; - position: absolute; - width: 100%; -} -.pswp__caption__center { - color: var(--white-color); - font-size: 11px; - line-height: 1.6; - margin: 0 auto; - max-width: 420px; - padding: 25px 15px 30px; - text-align: center; -} -.pswp__caption__center .post-caption-title { - font-size: 15px; - font-weight: 500; - margin-bottom: 7px; - text-transform: uppercase; -} -.pswp__caption__center .post-caption-meta-item + .post-caption-meta-item:before { - content: "\02022"; - padding: 0 4px; -} -.pswp__caption--empty { - display: none; -} -.pswp__caption--fake { - visibility: hidden; -} -.pswp__preloader { - direction: ltr; - height: 44px; - left: 50%; - margin-left: -22px; - opacity: 0; - position: absolute; - top: 0; - transition: opacity 0.25s ease-out; - width: 44px; - will-change: opacity; -} -.pswp__preloader__icn { - height: 20px; - margin: 12px; - width: 20px; -} -.pswp__preloader--active { - opacity: 1; -} -.pswp__preloader--active .pswp__preloader__icn { - background: url(../images/preloader.gif) 0 0 no-repeat; -} -.pswp--css_animation .pswp__preloader--active { - opacity: 1; -} -.pswp--css_animation .pswp__preloader--active .pswp__preloader__icn { - animation: clockwise 500ms linear infinite; -} -.pswp--css_animation .pswp__preloader--active .pswp__preloader__donut { - animation: donut-rotate 1000ms cubic-bezier(0.4, 0, 0.22, 1) infinite; -} -.pswp--css_animation .pswp__preloader__icn { - background: none; - height: 14px; - left: 15px; - margin: 0; - opacity: 0.75; - position: absolute; - top: 15px; - width: 14px; -} -.pswp--css_animation .pswp__preloader__cut { - height: 14px; - overflow: hidden; - position: relative; - width: 7px; -} -.pswp--css_animation .pswp__preloader__donut { - background: none; - border: 2px solid var(--white-color); - border-bottom-color: transparent; - border-left-color: transparent; - border-radius: 50%; - box-sizing: border-box; - height: 14px; - left: 0; - margin: 0; - position: absolute; - top: 0; - width: 14px; -} -@media screen and (max-width: 1024px) { - .pswp__preloader { - float: right; - left: auto; - margin: 0; - position: relative; - top: auto; - } -} -@keyframes clockwise { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -@keyframes donut-rotate { - 0% { - transform: rotate(0); - } - 50% { - transform: rotate(-140deg); - } - 100% { - transform: rotate(0); - } -} -.pswp__ui { - opacity: 1; - visibility: visible; - z-index: 1550; - -webkit-font-smoothing: auto; -} -.pswp__top-bar { - height: 44px; - left: 0; - position: absolute; - top: 0; - width: 100%; -} -.pswp__caption, -.pswp__top-bar, -.pswp--has_mouse .pswp__button--arrow--left, -.pswp--has_mouse .pswp__button--arrow--right { - backface-visibility: hidden; - transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); - will-change: opacity; -} -.pswp--has_mouse .pswp__button--arrow--left, -.pswp--has_mouse .pswp__button--arrow--right { - visibility: visible; -} -.pswp__ui--idle .pswp__top-bar { - opacity: 0; -} -.pswp__ui--idle .pswp__button--arrow--left, -.pswp__ui--idle .pswp__button--arrow--right { - opacity: 0; -} -.pswp__ui--hidden .pswp__top-bar, -.pswp__ui--hidden .pswp__caption, -.pswp__ui--hidden .pswp__button--arrow--left, -.pswp__ui--hidden .pswp__button--arrow--right { - opacity: 0.001; -} -.pswp__ui--one-slide .pswp__button--arrow--left, -.pswp__ui--one-slide .pswp__button--arrow--right, -.pswp__ui--one-slide .pswp__counter { - display: none; -} -.pswp__element--disabled { - display: none !important; -} -.pswp--minimal--dark .pswp__top-bar { - background: none; -} diff --git a/assets/images/footer-logo.png b/assets/images/footer-logo.png new file mode 100644 index 00000000..b49629d6 Binary files /dev/null and b/assets/images/footer-logo.png differ diff --git a/assets/images/home-impact.png b/assets/images/home-impact.png new file mode 100644 index 00000000..7ee12bd4 Binary files /dev/null and b/assets/images/home-impact.png differ diff --git a/assets/images/home-starter.png b/assets/images/home-starter.png new file mode 100644 index 00000000..be89f68d Binary files /dev/null and b/assets/images/home-starter.png differ diff --git a/assets/js/lib/owl.carousel.js b/assets/js/lib/owl.carousel.js deleted file mode 100644 index 66c67ebe..00000000 --- a/assets/js/lib/owl.carousel.js +++ /dev/null @@ -1,3448 +0,0 @@ -/** - * Owl Carousel v2.3.4 - * Copyright 2013-2018 David Deutsch - * Licensed under: SEE LICENSE IN https://github.com/OwlCarousel2/OwlCarousel2/blob/master/LICENSE - */ -/** - * Owl carousel - * @version 2.3.4 - * @author Bartosz Wojciechowski - * @author David Deutsch - * @license The MIT License (MIT) - * @todo Lazy Load Icon - * @todo prevent animationend bubling - * @todo itemsScaleUp - * @todo Test Zepto - * @todo stagePadding calculate wrong active classes - */ -;(function($, window, document, undefined) { - - /** - * Creates a carousel. - * @class The Owl Carousel. - * @public - * @param {HTMLElement|jQuery} element - The element to create the carousel for. - * @param {Object} [options] - The options - */ - function Owl(element, options) { - - /** - * Current settings for the carousel. - * @public - */ - this.settings = null; - - /** - * Current options set by the caller including defaults. - * @public - */ - this.options = $.extend({}, Owl.Defaults, options); - - /** - * Plugin element. - * @public - */ - this.$element = $(element); - - /** - * Proxied event handlers. - * @protected - */ - this._handlers = {}; - - /** - * References to the running plugins of this carousel. - * @protected - */ - this._plugins = {}; - - /** - * Currently suppressed events to prevent them from being retriggered. - * @protected - */ - this._supress = {}; - - /** - * Absolute current position. - * @protected - */ - this._current = null; - - /** - * Animation speed in milliseconds. - * @protected - */ - this._speed = null; - - /** - * Coordinates of all items in pixel. - * @todo The name of this member is missleading. - * @protected - */ - this._coordinates = []; - - /** - * Current breakpoint. - * @todo Real media queries would be nice. - * @protected - */ - this._breakpoint = null; - - /** - * Current width of the plugin element. - */ - this._width = null; - - /** - * All real items. - * @protected - */ - this._items = []; - - /** - * All cloned items. - * @protected - */ - this._clones = []; - - /** - * Merge values of all items. - * @todo Maybe this could be part of a plugin. - * @protected - */ - this._mergers = []; - - /** - * Widths of all items. - */ - this._widths = []; - - /** - * Invalidated parts within the update process. - * @protected - */ - this._invalidated = {}; - - /** - * Ordered list of workers for the update process. - * @protected - */ - this._pipe = []; - - /** - * Current state information for the drag operation. - * @todo #261 - * @protected - */ - this._drag = { - time: null, - target: null, - pointer: null, - stage: { - start: null, - current: null - }, - direction: null - }; - - /** - * Current state information and their tags. - * @type {Object} - * @protected - */ - this._states = { - current: {}, - tags: { - 'initializing': [ 'busy' ], - 'animating': [ 'busy' ], - 'dragging': [ 'interacting' ] - } - }; - - $.each([ 'onResize', 'onThrottledResize' ], $.proxy(function(i, handler) { - this._handlers[handler] = $.proxy(this[handler], this); - }, this)); - - $.each(Owl.Plugins, $.proxy(function(key, plugin) { - this._plugins[key.charAt(0).toLowerCase() + key.slice(1)] - = new plugin(this); - }, this)); - - $.each(Owl.Workers, $.proxy(function(priority, worker) { - this._pipe.push({ - 'filter': worker.filter, - 'run': $.proxy(worker.run, this) - }); - }, this)); - - this.setup(); - this.initialize(); - } - - /** - * Default options for the carousel. - * @public - */ - Owl.Defaults = { - items: 3, - loop: false, - center: false, - rewind: false, - checkVisibility: true, - - mouseDrag: true, - touchDrag: true, - pullDrag: true, - freeDrag: false, - - margin: 0, - stagePadding: 0, - - merge: false, - mergeFit: true, - autoWidth: false, - - startPosition: 0, - rtl: false, - - smartSpeed: 250, - fluidSpeed: false, - dragEndSpeed: false, - - responsive: {}, - responsiveRefreshRate: 200, - responsiveBaseElement: window, - - fallbackEasing: 'swing', - slideTransition: '', - - info: false, - - nestedItemSelector: false, - itemElement: 'div', - stageElement: 'div', - - refreshClass: 'owl-refresh', - loadedClass: 'owl-loaded', - loadingClass: 'owl-loading', - rtlClass: 'owl-rtl', - responsiveClass: 'owl-responsive', - dragClass: 'owl-drag', - itemClass: 'owl-item', - stageClass: 'owl-stage', - stageOuterClass: 'owl-stage-outer', - grabClass: 'owl-grab' - }; - - /** - * Enumeration for width. - * @public - * @readonly - * @enum {String} - */ - Owl.Width = { - Default: 'default', - Inner: 'inner', - Outer: 'outer' - }; - - /** - * Enumeration for types. - * @public - * @readonly - * @enum {String} - */ - Owl.Type = { - Event: 'event', - State: 'state' - }; - - /** - * Contains all registered plugins. - * @public - */ - Owl.Plugins = {}; - - /** - * List of workers involved in the update process. - */ - Owl.Workers = [ { - filter: [ 'width', 'settings' ], - run: function() { - this._width = this.$element.width(); - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function(cache) { - cache.current = this._items && this._items[this.relative(this._current)]; - } - }, { - filter: [ 'items', 'settings' ], - run: function() { - this.$stage.children('.cloned').remove(); - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function(cache) { - var margin = this.settings.margin || '', - grid = !this.settings.autoWidth, - rtl = this.settings.rtl, - css = { - 'width': 'auto', - 'margin-left': rtl ? margin : '', - 'margin-right': rtl ? '' : margin - }; - - !grid && this.$stage.children().css(css); - - cache.css = css; - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function(cache) { - var width = (this.width() / this.settings.items).toFixed(3) - this.settings.margin, - merge = null, - iterator = this._items.length, - grid = !this.settings.autoWidth, - widths = []; - - cache.items = { - merge: false, - width: width - }; - - while (iterator--) { - merge = this._mergers[iterator]; - merge = this.settings.mergeFit && Math.min(merge, this.settings.items) || merge; - - cache.items.merge = merge > 1 || cache.items.merge; - - widths[iterator] = !grid ? this._items[iterator].width() : width * merge; - } - - this._widths = widths; - } - }, { - filter: [ 'items', 'settings' ], - run: function() { - var clones = [], - items = this._items, - settings = this.settings, - // TODO: Should be computed from number of min width items in stage - view = Math.max(settings.items * 2, 4), - size = Math.ceil(items.length / 2) * 2, - repeat = settings.loop && items.length ? settings.rewind ? view : Math.max(view, size) : 0, - append = '', - prepend = ''; - - repeat /= 2; - - while (repeat > 0) { - // Switch to only using appended clones - clones.push(this.normalize(clones.length / 2, true)); - append = append + items[clones[clones.length - 1]][0].outerHTML; - clones.push(this.normalize(items.length - 1 - (clones.length - 1) / 2, true)); - prepend = items[clones[clones.length - 1]][0].outerHTML + prepend; - repeat -= 1; - } - - this._clones = clones; - - $(append).addClass('cloned').appendTo(this.$stage); - $(prepend).addClass('cloned').prependTo(this.$stage); - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function() { - var rtl = this.settings.rtl ? 1 : -1, - size = this._clones.length + this._items.length, - iterator = -1, - previous = 0, - current = 0, - coordinates = []; - - while (++iterator < size) { - previous = coordinates[iterator - 1] || 0; - current = this._widths[this.relative(iterator)] + this.settings.margin; - coordinates.push(previous + current * rtl); - } - - this._coordinates = coordinates; - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function() { - var padding = this.settings.stagePadding, - coordinates = this._coordinates, - css = { - 'width': Math.ceil(Math.abs(coordinates[coordinates.length - 1])) + padding * 2, - 'padding-left': padding || '', - 'padding-right': padding || '' - }; - - this.$stage.css(css); - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function(cache) { - var iterator = this._coordinates.length, - grid = !this.settings.autoWidth, - items = this.$stage.children(); - - if (grid && cache.items.merge) { - while (iterator--) { - cache.css.width = this._widths[this.relative(iterator)]; - items.eq(iterator).css(cache.css); - } - } else if (grid) { - cache.css.width = cache.items.width; - items.css(cache.css); - } - } - }, { - filter: [ 'items' ], - run: function() { - this._coordinates.length < 1 && this.$stage.removeAttr('style'); - } - }, { - filter: [ 'width', 'items', 'settings' ], - run: function(cache) { - cache.current = cache.current ? this.$stage.children().index(cache.current) : 0; - cache.current = Math.max(this.minimum(), Math.min(this.maximum(), cache.current)); - this.reset(cache.current); - } - }, { - filter: [ 'position' ], - run: function() { - this.animate(this.coordinates(this._current)); - } - }, { - filter: [ 'width', 'position', 'items', 'settings' ], - run: function() { - var rtl = this.settings.rtl ? 1 : -1, - padding = this.settings.stagePadding * 2, - begin = this.coordinates(this.current()) + padding, - end = begin + this.width() * rtl, - inner, outer, matches = [], i, n; - - for (i = 0, n = this._coordinates.length; i < n; i++) { - inner = this._coordinates[i - 1] || 0; - outer = Math.abs(this._coordinates[i]) + padding * rtl; - - if ((this.op(inner, '<=', begin) && (this.op(inner, '>', end))) - || (this.op(outer, '<', begin) && this.op(outer, '>', end))) { - matches.push(i); - } - } - - this.$stage.children('.active').removeClass('active'); - this.$stage.children(':eq(' + matches.join('), :eq(') + ')').addClass('active'); - - this.$stage.children('.center').removeClass('center'); - if (this.settings.center) { - this.$stage.children().eq(this.current()).addClass('center'); - } - } - } ]; - - /** - * Create the stage DOM element - */ - Owl.prototype.initializeStage = function() { - this.$stage = this.$element.find('.' + this.settings.stageClass); - - // if the stage is already in the DOM, grab it and skip stage initialization - if (this.$stage.length) { - return; - } - - this.$element.addClass(this.options.loadingClass); - - // create stage - this.$stage = $('<' + this.settings.stageElement + '>', { - "class": this.settings.stageClass - }).wrap( $( '
', { - "class": this.settings.stageOuterClass - })); - - // append stage - this.$element.append(this.$stage.parent()); - }; - - /** - * Create item DOM elements - */ - Owl.prototype.initializeItems = function() { - var $items = this.$element.find('.owl-item'); - - // if the items are already in the DOM, grab them and skip item initialization - if ($items.length) { - this._items = $items.get().map(function(item) { - return $(item); - }); - - this._mergers = this._items.map(function() { - return 1; - }); - - this.refresh(); - - return; - } - - // append content - this.replace(this.$element.children().not(this.$stage.parent())); - - // check visibility - if (this.isVisible()) { - // update view - this.refresh(); - } else { - // invalidate width - this.invalidate('width'); - } - - this.$element - .removeClass(this.options.loadingClass) - .addClass(this.options.loadedClass); - }; - - /** - * Initializes the carousel. - * @protected - */ - Owl.prototype.initialize = function() { - this.enter('initializing'); - this.trigger('initialize'); - - this.$element.toggleClass(this.settings.rtlClass, this.settings.rtl); - - if (this.settings.autoWidth && !this.is('pre-loading')) { - var imgs, nestedSelector, width; - imgs = this.$element.find('img'); - nestedSelector = this.settings.nestedItemSelector ? '.' + this.settings.nestedItemSelector : undefined; - width = this.$element.children(nestedSelector).width(); - - if (imgs.length && width <= 0) { - this.preloadAutoWidthImages(imgs); - } - } - - this.initializeStage(); - this.initializeItems(); - - // register event handlers - this.registerEventHandlers(); - - this.leave('initializing'); - this.trigger('initialized'); - }; - - /** - * @returns {Boolean} visibility of $element - * if you know the carousel will always be visible you can set `checkVisibility` to `false` to - * prevent the expensive browser layout forced reflow the $element.is(':visible') does - */ - Owl.prototype.isVisible = function() { - return this.settings.checkVisibility - ? this.$element.is(':visible') - : true; - }; - - /** - * Setups the current settings. - * @todo Remove responsive classes. Why should adaptive designs be brought into IE8? - * @todo Support for media queries by using `matchMedia` would be nice. - * @public - */ - Owl.prototype.setup = function() { - var viewport = this.viewport(), - overwrites = this.options.responsive, - match = -1, - settings = null; - - if (!overwrites) { - settings = $.extend({}, this.options); - } else { - $.each(overwrites, function(breakpoint) { - if (breakpoint <= viewport && breakpoint > match) { - match = Number(breakpoint); - } - }); - - settings = $.extend({}, this.options, overwrites[match]); - if (typeof settings.stagePadding === 'function') { - settings.stagePadding = settings.stagePadding(); - } - delete settings.responsive; - - // responsive class - if (settings.responsiveClass) { - this.$element.attr('class', - this.$element.attr('class').replace(new RegExp('(' + this.options.responsiveClass + '-)\\S+\\s', 'g'), '$1' + match) - ); - } - } - - this.trigger('change', { property: { name: 'settings', value: settings } }); - this._breakpoint = match; - this.settings = settings; - this.invalidate('settings'); - this.trigger('changed', { property: { name: 'settings', value: this.settings } }); - }; - - /** - * Updates option logic if necessery. - * @protected - */ - Owl.prototype.optionsLogic = function() { - if (this.settings.autoWidth) { - this.settings.stagePadding = false; - this.settings.merge = false; - } - }; - - /** - * Prepares an item before add. - * @todo Rename event parameter `content` to `item`. - * @protected - * @returns {jQuery|HTMLElement} - The item container. - */ - Owl.prototype.prepare = function(item) { - var event = this.trigger('prepare', { content: item }); - - if (!event.data) { - event.data = $('<' + this.settings.itemElement + '/>') - .addClass(this.options.itemClass).append(item) - } - - this.trigger('prepared', { content: event.data }); - - return event.data; - }; - - /** - * Updates the view. - * @public - */ - Owl.prototype.update = function() { - var i = 0, - n = this._pipe.length, - filter = $.proxy(function(p) { return this[p] }, this._invalidated), - cache = {}; - - while (i < n) { - if (this._invalidated.all || $.grep(this._pipe[i].filter, filter).length > 0) { - this._pipe[i].run(cache); - } - i++; - } - - this._invalidated = {}; - - !this.is('valid') && this.enter('valid'); - }; - - /** - * Gets the width of the view. - * @public - * @param {Owl.Width} [dimension=Owl.Width.Default] - The dimension to return. - * @returns {Number} - The width of the view in pixel. - */ - Owl.prototype.width = function(dimension) { - dimension = dimension || Owl.Width.Default; - switch (dimension) { - case Owl.Width.Inner: - case Owl.Width.Outer: - return this._width; - default: - return this._width - this.settings.stagePadding * 2 + this.settings.margin; - } - }; - - /** - * Refreshes the carousel primarily for adaptive purposes. - * @public - */ - Owl.prototype.refresh = function() { - this.enter('refreshing'); - this.trigger('refresh'); - - this.setup(); - - this.optionsLogic(); - - this.$element.addClass(this.options.refreshClass); - - this.update(); - - this.$element.removeClass(this.options.refreshClass); - - this.leave('refreshing'); - this.trigger('refreshed'); - }; - - /** - * Checks window `resize` event. - * @protected - */ - Owl.prototype.onThrottledResize = function() { - window.clearTimeout(this.resizeTimer); - this.resizeTimer = window.setTimeout(this._handlers.onResize, this.settings.responsiveRefreshRate); - }; - - /** - * Checks window `resize` event. - * @protected - */ - Owl.prototype.onResize = function() { - if (!this._items.length) { - return false; - } - - if (this._width === this.$element.width()) { - return false; - } - - if (!this.isVisible()) { - return false; - } - - this.enter('resizing'); - - if (this.trigger('resize').isDefaultPrevented()) { - this.leave('resizing'); - return false; - } - - this.invalidate('width'); - - this.refresh(); - - this.leave('resizing'); - this.trigger('resized'); - }; - - /** - * Registers event handlers. - * @todo Check `msPointerEnabled` - * @todo #261 - * @protected - */ - Owl.prototype.registerEventHandlers = function() { - if ($.support.transition) { - this.$stage.on($.support.transition.end + '.owl.core', $.proxy(this.onTransitionEnd, this)); - } - - if (this.settings.responsive !== false) { - this.on(window, 'resize', this._handlers.onThrottledResize); - } - - if (this.settings.mouseDrag) { - this.$element.addClass(this.options.dragClass); - this.$stage.on('mousedown.owl.core', $.proxy(this.onDragStart, this)); - this.$stage.on('dragstart.owl.core selectstart.owl.core', function() { return false }); - } - - if (this.settings.touchDrag){ - this.$stage.on('touchstart.owl.core', $.proxy(this.onDragStart, this)); - this.$stage.on('touchcancel.owl.core', $.proxy(this.onDragEnd, this)); - } - }; - - /** - * Handles `touchstart` and `mousedown` events. - * @todo Horizontal swipe threshold as option - * @todo #261 - * @protected - * @param {Event} event - The event arguments. - */ - Owl.prototype.onDragStart = function(event) { - var stage = null; - - if (event.which === 3) { - return; - } - - if ($.support.transform) { - stage = this.$stage.css('transform').replace(/.*\(|\)| /g, '').split(','); - stage = { - x: stage[stage.length === 16 ? 12 : 4], - y: stage[stage.length === 16 ? 13 : 5] - }; - } else { - stage = this.$stage.position(); - stage = { - x: this.settings.rtl ? - stage.left + this.$stage.width() - this.width() + this.settings.margin : - stage.left, - y: stage.top - }; - } - - if (this.is('animating')) { - $.support.transform ? this.animate(stage.x) : this.$stage.stop() - this.invalidate('position'); - } - - this.$element.toggleClass(this.options.grabClass, event.type === 'mousedown'); - - this.speed(0); - - this._drag.time = new Date().getTime(); - this._drag.target = $(event.target); - this._drag.stage.start = stage; - this._drag.stage.current = stage; - this._drag.pointer = this.pointer(event); - - $(document).on('mouseup.owl.core touchend.owl.core', $.proxy(this.onDragEnd, this)); - - $(document).one('mousemove.owl.core touchmove.owl.core', $.proxy(function(event) { - var delta = this.difference(this._drag.pointer, this.pointer(event)); - - $(document).on('mousemove.owl.core touchmove.owl.core', $.proxy(this.onDragMove, this)); - - if (Math.abs(delta.x) < Math.abs(delta.y) && this.is('valid')) { - return; - } - - event.preventDefault(); - - this.enter('dragging'); - this.trigger('drag'); - }, this)); - }; - - /** - * Handles the `touchmove` and `mousemove` events. - * @todo #261 - * @protected - * @param {Event} event - The event arguments. - */ - Owl.prototype.onDragMove = function(event) { - var minimum = null, - maximum = null, - pull = null, - delta = this.difference(this._drag.pointer, this.pointer(event)), - stage = this.difference(this._drag.stage.start, delta); - - if (!this.is('dragging')) { - return; - } - - event.preventDefault(); - - if (this.settings.loop) { - minimum = this.coordinates(this.minimum()); - maximum = this.coordinates(this.maximum() + 1) - minimum; - stage.x = (((stage.x - minimum) % maximum + maximum) % maximum) + minimum; - } else { - minimum = this.settings.rtl ? this.coordinates(this.maximum()) : this.coordinates(this.minimum()); - maximum = this.settings.rtl ? this.coordinates(this.minimum()) : this.coordinates(this.maximum()); - pull = this.settings.pullDrag ? -1 * delta.x / 5 : 0; - stage.x = Math.max(Math.min(stage.x, minimum + pull), maximum + pull); - } - - this._drag.stage.current = stage; - - this.animate(stage.x); - }; - - /** - * Handles the `touchend` and `mouseup` events. - * @todo #261 - * @todo Threshold for click event - * @protected - * @param {Event} event - The event arguments. - */ - Owl.prototype.onDragEnd = function(event) { - var delta = this.difference(this._drag.pointer, this.pointer(event)), - stage = this._drag.stage.current, - direction = delta.x > 0 ^ this.settings.rtl ? 'left' : 'right'; - - $(document).off('.owl.core'); - - this.$element.removeClass(this.options.grabClass); - - if (delta.x !== 0 && this.is('dragging') || !this.is('valid')) { - this.speed(this.settings.dragEndSpeed || this.settings.smartSpeed); - this.current(this.closest(stage.x, delta.x !== 0 ? direction : this._drag.direction)); - this.invalidate('position'); - this.update(); - - this._drag.direction = direction; - - if (Math.abs(delta.x) > 3 || new Date().getTime() - this._drag.time > 300) { - this._drag.target.one('click.owl.core', function() { return false; }); - } - } - - if (!this.is('dragging')) { - return; - } - - this.leave('dragging'); - this.trigger('dragged'); - }; - - /** - * Gets absolute position of the closest item for a coordinate. - * @todo Setting `freeDrag` makes `closest` not reusable. See #165. - * @protected - * @param {Number} coordinate - The coordinate in pixel. - * @param {String} direction - The direction to check for the closest item. Ether `left` or `right`. - * @return {Number} - The absolute position of the closest item. - */ - Owl.prototype.closest = function(coordinate, direction) { - var position = -1, - pull = 30, - width = this.width(), - coordinates = this.coordinates(); - - if (!this.settings.freeDrag) { - // check closest item - $.each(coordinates, $.proxy(function(index, value) { - // on a left pull, check on current index - if (direction === 'left' && coordinate > value - pull && coordinate < value + pull) { - position = index; - // on a right pull, check on previous index - // to do so, subtract width from value and set position = index + 1 - } else if (direction === 'right' && coordinate > value - width - pull && coordinate < value - width + pull) { - position = index + 1; - } else if (this.op(coordinate, '<', value) - && this.op(coordinate, '>', coordinates[index + 1] !== undefined ? coordinates[index + 1] : value - width)) { - position = direction === 'left' ? index + 1 : index; - } - return position === -1; - }, this)); - } - - if (!this.settings.loop) { - // non loop boundries - if (this.op(coordinate, '>', coordinates[this.minimum()])) { - position = coordinate = this.minimum(); - } else if (this.op(coordinate, '<', coordinates[this.maximum()])) { - position = coordinate = this.maximum(); - } - } - - return position; - }; - - /** - * Animates the stage. - * @todo #270 - * @public - * @param {Number} coordinate - The coordinate in pixels. - */ - Owl.prototype.animate = function(coordinate) { - var animate = this.speed() > 0; - - this.is('animating') && this.onTransitionEnd(); - - if (animate) { - this.enter('animating'); - this.trigger('translate'); - } - - if ($.support.transform3d && $.support.transition) { - this.$stage.css({ - transform: 'translate3d(' + coordinate + 'px,0px,0px)', - transition: (this.speed() / 1000) + 's' + ( - this.settings.slideTransition ? ' ' + this.settings.slideTransition : '' - ) - }); - } else if (animate) { - this.$stage.animate({ - left: coordinate + 'px' - }, this.speed(), this.settings.fallbackEasing, $.proxy(this.onTransitionEnd, this)); - } else { - this.$stage.css({ - left: coordinate + 'px' - }); - } - }; - - /** - * Checks whether the carousel is in a specific state or not. - * @param {String} state - The state to check. - * @returns {Boolean} - The flag which indicates if the carousel is busy. - */ - Owl.prototype.is = function(state) { - return this._states.current[state] && this._states.current[state] > 0; - }; - - /** - * Sets the absolute position of the current item. - * @public - * @param {Number} [position] - The new absolute position or nothing to leave it unchanged. - * @returns {Number} - The absolute position of the current item. - */ - Owl.prototype.current = function(position) { - if (position === undefined) { - return this._current; - } - - if (this._items.length === 0) { - return undefined; - } - - position = this.normalize(position); - - if (this._current !== position) { - var event = this.trigger('change', { property: { name: 'position', value: position } }); - - if (event.data !== undefined) { - position = this.normalize(event.data); - } - - this._current = position; - - this.invalidate('position'); - - this.trigger('changed', { property: { name: 'position', value: this._current } }); - } - - return this._current; - }; - - /** - * Invalidates the given part of the update routine. - * @param {String} [part] - The part to invalidate. - * @returns {Array.} - The invalidated parts. - */ - Owl.prototype.invalidate = function(part) { - if ($.type(part) === 'string') { - this._invalidated[part] = true; - this.is('valid') && this.leave('valid'); - } - return $.map(this._invalidated, function(v, i) { return i }); - }; - - /** - * Resets the absolute position of the current item. - * @public - * @param {Number} position - The absolute position of the new item. - */ - Owl.prototype.reset = function(position) { - position = this.normalize(position); - - if (position === undefined) { - return; - } - - this._speed = 0; - this._current = position; - - this.suppress([ 'translate', 'translated' ]); - - this.animate(this.coordinates(position)); - - this.release([ 'translate', 'translated' ]); - }; - - /** - * Normalizes an absolute or a relative position of an item. - * @public - * @param {Number} position - The absolute or relative position to normalize. - * @param {Boolean} [relative=false] - Whether the given position is relative or not. - * @returns {Number} - The normalized position. - */ - Owl.prototype.normalize = function(position, relative) { - var n = this._items.length, - m = relative ? 0 : this._clones.length; - - if (!this.isNumeric(position) || n < 1) { - position = undefined; - } else if (position < 0 || position >= n + m) { - position = ((position - m / 2) % n + n) % n + m / 2; - } - - return position; - }; - - /** - * Converts an absolute position of an item into a relative one. - * @public - * @param {Number} position - The absolute position to convert. - * @returns {Number} - The converted position. - */ - Owl.prototype.relative = function(position) { - position -= this._clones.length / 2; - return this.normalize(position, true); - }; - - /** - * Gets the maximum position for the current item. - * @public - * @param {Boolean} [relative=false] - Whether to return an absolute position or a relative position. - * @returns {Number} - */ - Owl.prototype.maximum = function(relative) { - var settings = this.settings, - maximum = this._coordinates.length, - iterator, - reciprocalItemsWidth, - elementWidth; - - if (settings.loop) { - maximum = this._clones.length / 2 + this._items.length - 1; - } else if (settings.autoWidth || settings.merge) { - iterator = this._items.length; - if (iterator) { - reciprocalItemsWidth = this._items[--iterator].width(); - elementWidth = this.$element.width(); - while (iterator--) { - reciprocalItemsWidth += this._items[iterator].width() + this.settings.margin; - if (reciprocalItemsWidth > elementWidth) { - break; - } - } - } - maximum = iterator + 1; - } else if (settings.center) { - maximum = this._items.length - 1; - } else { - maximum = this._items.length - settings.items; - } - - if (relative) { - maximum -= this._clones.length / 2; - } - - return Math.max(maximum, 0); - }; - - /** - * Gets the minimum position for the current item. - * @public - * @param {Boolean} [relative=false] - Whether to return an absolute position or a relative position. - * @returns {Number} - */ - Owl.prototype.minimum = function(relative) { - return relative ? 0 : this._clones.length / 2; - }; - - /** - * Gets an item at the specified relative position. - * @public - * @param {Number} [position] - The relative position of the item. - * @return {jQuery|Array.} - The item at the given position or all items if no position was given. - */ - Owl.prototype.items = function(position) { - if (position === undefined) { - return this._items.slice(); - } - - position = this.normalize(position, true); - return this._items[position]; - }; - - /** - * Gets an item at the specified relative position. - * @public - * @param {Number} [position] - The relative position of the item. - * @return {jQuery|Array.} - The item at the given position or all items if no position was given. - */ - Owl.prototype.mergers = function(position) { - if (position === undefined) { - return this._mergers.slice(); - } - - position = this.normalize(position, true); - return this._mergers[position]; - }; - - /** - * Gets the absolute positions of clones for an item. - * @public - * @param {Number} [position] - The relative position of the item. - * @returns {Array.} - The absolute positions of clones for the item or all if no position was given. - */ - Owl.prototype.clones = function(position) { - var odd = this._clones.length / 2, - even = odd + this._items.length, - map = function(index) { return index % 2 === 0 ? even + index / 2 : odd - (index + 1) / 2 }; - - if (position === undefined) { - return $.map(this._clones, function(v, i) { return map(i) }); - } - - return $.map(this._clones, function(v, i) { return v === position ? map(i) : null }); - }; - - /** - * Sets the current animation speed. - * @public - * @param {Number} [speed] - The animation speed in milliseconds or nothing to leave it unchanged. - * @returns {Number} - The current animation speed in milliseconds. - */ - Owl.prototype.speed = function(speed) { - if (speed !== undefined) { - this._speed = speed; - } - - return this._speed; - }; - - /** - * Gets the coordinate of an item. - * @todo The name of this method is missleanding. - * @public - * @param {Number} position - The absolute position of the item within `minimum()` and `maximum()`. - * @returns {Number|Array.} - The coordinate of the item in pixel or all coordinates. - */ - Owl.prototype.coordinates = function(position) { - var multiplier = 1, - newPosition = position - 1, - coordinate; - - if (position === undefined) { - return $.map(this._coordinates, $.proxy(function(coordinate, index) { - return this.coordinates(index); - }, this)); - } - - if (this.settings.center) { - if (this.settings.rtl) { - multiplier = -1; - newPosition = position + 1; - } - - coordinate = this._coordinates[position]; - coordinate += (this.width() - coordinate + (this._coordinates[newPosition] || 0)) / 2 * multiplier; - } else { - coordinate = this._coordinates[newPosition] || 0; - } - - coordinate = Math.ceil(coordinate); - - return coordinate; - }; - - /** - * Calculates the speed for a translation. - * @protected - * @param {Number} from - The absolute position of the start item. - * @param {Number} to - The absolute position of the target item. - * @param {Number} [factor=undefined] - The time factor in milliseconds. - * @returns {Number} - The time in milliseconds for the translation. - */ - Owl.prototype.duration = function(from, to, factor) { - if (factor === 0) { - return 0; - } - - return Math.min(Math.max(Math.abs(to - from), 1), 6) * Math.abs((factor || this.settings.smartSpeed)); - }; - - /** - * Slides to the specified item. - * @public - * @param {Number} position - The position of the item. - * @param {Number} [speed] - The time in milliseconds for the transition. - */ - Owl.prototype.to = function(position, speed) { - var current = this.current(), - revert = null, - distance = position - this.relative(current), - direction = (distance > 0) - (distance < 0), - items = this._items.length, - minimum = this.minimum(), - maximum = this.maximum(); - - if (this.settings.loop) { - if (!this.settings.rewind && Math.abs(distance) > items / 2) { - distance += direction * -1 * items; - } - - position = current + distance; - revert = ((position - minimum) % items + items) % items + minimum; - - if (revert !== position && revert - distance <= maximum && revert - distance > 0) { - current = revert - distance; - position = revert; - this.reset(current); - } - } else if (this.settings.rewind) { - maximum += 1; - position = (position % maximum + maximum) % maximum; - } else { - position = Math.max(minimum, Math.min(maximum, position)); - } - - this.speed(this.duration(current, position, speed)); - this.current(position); - - if (this.isVisible()) { - this.update(); - } - }; - - /** - * Slides to the next item. - * @public - * @param {Number} [speed] - The time in milliseconds for the transition. - */ - Owl.prototype.next = function(speed) { - speed = speed || false; - this.to(this.relative(this.current()) + 1, speed); - }; - - /** - * Slides to the previous item. - * @public - * @param {Number} [speed] - The time in milliseconds for the transition. - */ - Owl.prototype.prev = function(speed) { - speed = speed || false; - this.to(this.relative(this.current()) - 1, speed); - }; - - /** - * Handles the end of an animation. - * @protected - * @param {Event} event - The event arguments. - */ - Owl.prototype.onTransitionEnd = function(event) { - - // if css2 animation then event object is undefined - if (event !== undefined) { - event.stopPropagation(); - - // Catch only owl-stage transitionEnd event - if ((event.target || event.srcElement || event.originalTarget) !== this.$stage.get(0)) { - return false; - } - } - - this.leave('animating'); - this.trigger('translated'); - }; - - /** - * Gets viewport width. - * @protected - * @return {Number} - The width in pixel. - */ - Owl.prototype.viewport = function() { - var width; - if (this.options.responsiveBaseElement !== window) { - width = $(this.options.responsiveBaseElement).width(); - } else if (window.innerWidth) { - width = window.innerWidth; - } else if (document.documentElement && document.documentElement.clientWidth) { - width = document.documentElement.clientWidth; - } else { - console.warn('Can not detect viewport width.'); - } - return width; - }; - - /** - * Replaces the current content. - * @public - * @param {HTMLElement|jQuery|String} content - The new content. - */ - Owl.prototype.replace = function(content) { - this.$stage.empty(); - this._items = []; - - if (content) { - content = (content instanceof jQuery) ? content : $(content); - } - - if (this.settings.nestedItemSelector) { - content = content.find('.' + this.settings.nestedItemSelector); - } - - content.filter(function() { - return this.nodeType === 1; - }).each($.proxy(function(index, item) { - item = this.prepare(item); - this.$stage.append(item); - this._items.push(item); - this._mergers.push(item.find('[data-merge]').addBack('[data-merge]').attr('data-merge') * 1 || 1); - }, this)); - - this.reset(this.isNumeric(this.settings.startPosition) ? this.settings.startPosition : 0); - - this.invalidate('items'); - }; - - /** - * Adds an item. - * @todo Use `item` instead of `content` for the event arguments. - * @public - * @param {HTMLElement|jQuery|String} content - The item content to add. - * @param {Number} [position] - The relative position at which to insert the item otherwise the item will be added to the end. - */ - Owl.prototype.add = function(content, position) { - var current = this.relative(this._current); - - position = position === undefined ? this._items.length : this.normalize(position, true); - content = content instanceof jQuery ? content : $(content); - - this.trigger('add', { content: content, position: position }); - - content = this.prepare(content); - - if (this._items.length === 0 || position === this._items.length) { - this._items.length === 0 && this.$stage.append(content); - this._items.length !== 0 && this._items[position - 1].after(content); - this._items.push(content); - this._mergers.push(content.find('[data-merge]').addBack('[data-merge]').attr('data-merge') * 1 || 1); - } else { - this._items[position].before(content); - this._items.splice(position, 0, content); - this._mergers.splice(position, 0, content.find('[data-merge]').addBack('[data-merge]').attr('data-merge') * 1 || 1); - } - - this._items[current] && this.reset(this._items[current].index()); - - this.invalidate('items'); - - this.trigger('added', { content: content, position: position }); - }; - - /** - * Removes an item by its position. - * @todo Use `item` instead of `content` for the event arguments. - * @public - * @param {Number} position - The relative position of the item to remove. - */ - Owl.prototype.remove = function(position) { - position = this.normalize(position, true); - - if (position === undefined) { - return; - } - - this.trigger('remove', { content: this._items[position], position: position }); - - this._items[position].remove(); - this._items.splice(position, 1); - this._mergers.splice(position, 1); - - this.invalidate('items'); - - this.trigger('removed', { content: null, position: position }); - }; - - /** - * Preloads images with auto width. - * @todo Replace by a more generic approach - * @protected - */ - Owl.prototype.preloadAutoWidthImages = function(images) { - images.each($.proxy(function(i, element) { - this.enter('pre-loading'); - element = $(element); - $(new Image()).one('load', $.proxy(function(e) { - element.attr('src', e.target.src); - element.css('opacity', 1); - this.leave('pre-loading'); - !this.is('pre-loading') && !this.is('initializing') && this.refresh(); - }, this)).attr('src', element.attr('src') || element.attr('data-src') || element.attr('data-src-retina')); - }, this)); - }; - - /** - * Destroys the carousel. - * @public - */ - Owl.prototype.destroy = function() { - - this.$element.off('.owl.core'); - this.$stage.off('.owl.core'); - $(document).off('.owl.core'); - - if (this.settings.responsive !== false) { - window.clearTimeout(this.resizeTimer); - this.off(window, 'resize', this._handlers.onThrottledResize); - } - - for (var i in this._plugins) { - this._plugins[i].destroy(); - } - - this.$stage.children('.cloned').remove(); - - this.$stage.unwrap(); - this.$stage.children().contents().unwrap(); - this.$stage.children().unwrap(); - this.$stage.remove(); - this.$element - .removeClass(this.options.refreshClass) - .removeClass(this.options.loadingClass) - .removeClass(this.options.loadedClass) - .removeClass(this.options.rtlClass) - .removeClass(this.options.dragClass) - .removeClass(this.options.grabClass) - .attr('class', this.$element.attr('class').replace(new RegExp(this.options.responsiveClass + '-\\S+\\s', 'g'), '')) - .removeData('owl.carousel'); - }; - - /** - * Operators to calculate right-to-left and left-to-right. - * @protected - * @param {Number} [a] - The left side operand. - * @param {String} [o] - The operator. - * @param {Number} [b] - The right side operand. - */ - Owl.prototype.op = function(a, o, b) { - var rtl = this.settings.rtl; - switch (o) { - case '<': - return rtl ? a > b : a < b; - case '>': - return rtl ? a < b : a > b; - case '>=': - return rtl ? a <= b : a >= b; - case '<=': - return rtl ? a >= b : a <= b; - default: - break; - } - }; - - /** - * Attaches to an internal event. - * @protected - * @param {HTMLElement} element - The event source. - * @param {String} event - The event name. - * @param {Function} listener - The event handler to attach. - * @param {Boolean} capture - Wether the event should be handled at the capturing phase or not. - */ - Owl.prototype.on = function(element, event, listener, capture) { - if (element.addEventListener) { - element.addEventListener(event, listener, capture); - } else if (element.attachEvent) { - element.attachEvent('on' + event, listener); - } - }; - - /** - * Detaches from an internal event. - * @protected - * @param {HTMLElement} element - The event source. - * @param {String} event - The event name. - * @param {Function} listener - The attached event handler to detach. - * @param {Boolean} capture - Wether the attached event handler was registered as a capturing listener or not. - */ - Owl.prototype.off = function(element, event, listener, capture) { - if (element.removeEventListener) { - element.removeEventListener(event, listener, capture); - } else if (element.detachEvent) { - element.detachEvent('on' + event, listener); - } - }; - - /** - * Triggers a public event. - * @todo Remove `status`, `relatedTarget` should be used instead. - * @protected - * @param {String} name - The event name. - * @param {*} [data=null] - The event data. - * @param {String} [namespace=carousel] - The event namespace. - * @param {String} [state] - The state which is associated with the event. - * @param {Boolean} [enter=false] - Indicates if the call enters the specified state or not. - * @returns {Event} - The event arguments. - */ - Owl.prototype.trigger = function(name, data, namespace, state, enter) { - var status = { - item: { count: this._items.length, index: this.current() } - }, handler = $.camelCase( - $.grep([ 'on', name, namespace ], function(v) { return v }) - .join('-').toLowerCase() - ), event = $.Event( - [ name, 'owl', namespace || 'carousel' ].join('.').toLowerCase(), - $.extend({ relatedTarget: this }, status, data) - ); - - if (!this._supress[name]) { - $.each(this._plugins, function(name, plugin) { - if (plugin.onTrigger) { - plugin.onTrigger(event); - } - }); - - this.register({ type: Owl.Type.Event, name: name }); - this.$element.trigger(event); - - if (this.settings && typeof this.settings[handler] === 'function') { - this.settings[handler].call(this, event); - } - } - - return event; - }; - - /** - * Enters a state. - * @param name - The state name. - */ - Owl.prototype.enter = function(name) { - $.each([ name ].concat(this._states.tags[name] || []), $.proxy(function(i, name) { - if (this._states.current[name] === undefined) { - this._states.current[name] = 0; - } - - this._states.current[name]++; - }, this)); - }; - - /** - * Leaves a state. - * @param name - The state name. - */ - Owl.prototype.leave = function(name) { - $.each([ name ].concat(this._states.tags[name] || []), $.proxy(function(i, name) { - this._states.current[name]--; - }, this)); - }; - - /** - * Registers an event or state. - * @public - * @param {Object} object - The event or state to register. - */ - Owl.prototype.register = function(object) { - if (object.type === Owl.Type.Event) { - if (!$.event.special[object.name]) { - $.event.special[object.name] = {}; - } - - if (!$.event.special[object.name].owl) { - var _default = $.event.special[object.name]._default; - $.event.special[object.name]._default = function(e) { - if (_default && _default.apply && (!e.namespace || e.namespace.indexOf('owl') === -1)) { - return _default.apply(this, arguments); - } - return e.namespace && e.namespace.indexOf('owl') > -1; - }; - $.event.special[object.name].owl = true; - } - } else if (object.type === Owl.Type.State) { - if (!this._states.tags[object.name]) { - this._states.tags[object.name] = object.tags; - } else { - this._states.tags[object.name] = this._states.tags[object.name].concat(object.tags); - } - - this._states.tags[object.name] = $.grep(this._states.tags[object.name], $.proxy(function(tag, i) { - return $.inArray(tag, this._states.tags[object.name]) === i; - }, this)); - } - }; - - /** - * Suppresses events. - * @protected - * @param {Array.} events - The events to suppress. - */ - Owl.prototype.suppress = function(events) { - $.each(events, $.proxy(function(index, event) { - this._supress[event] = true; - }, this)); - }; - - /** - * Releases suppressed events. - * @protected - * @param {Array.} events - The events to release. - */ - Owl.prototype.release = function(events) { - $.each(events, $.proxy(function(index, event) { - delete this._supress[event]; - }, this)); - }; - - /** - * Gets unified pointer coordinates from event. - * @todo #261 - * @protected - * @param {Event} - The `mousedown` or `touchstart` event. - * @returns {Object} - Contains `x` and `y` coordinates of current pointer position. - */ - Owl.prototype.pointer = function(event) { - var result = { x: null, y: null }; - - event = event.originalEvent || event || window.event; - - event = event.touches && event.touches.length ? - event.touches[0] : event.changedTouches && event.changedTouches.length ? - event.changedTouches[0] : event; - - if (event.pageX) { - result.x = event.pageX; - result.y = event.pageY; - } else { - result.x = event.clientX; - result.y = event.clientY; - } - - return result; - }; - - /** - * Determines if the input is a Number or something that can be coerced to a Number - * @protected - * @param {Number|String|Object|Array|Boolean|RegExp|Function|Symbol} - The input to be tested - * @returns {Boolean} - An indication if the input is a Number or can be coerced to a Number - */ - Owl.prototype.isNumeric = function(number) { - return !isNaN(parseFloat(number)); - }; - - /** - * Gets the difference of two vectors. - * @todo #261 - * @protected - * @param {Object} - The first vector. - * @param {Object} - The second vector. - * @returns {Object} - The difference. - */ - Owl.prototype.difference = function(first, second) { - return { - x: first.x - second.x, - y: first.y - second.y - }; - }; - - /** - * The jQuery Plugin for the Owl Carousel - * @todo Navigation plugin `next` and `prev` - * @public - */ - $.fn.owlCarousel = function(option) { - var args = Array.prototype.slice.call(arguments, 1); - - return this.each(function() { - var $this = $(this), - data = $this.data('owl.carousel'); - - if (!data) { - data = new Owl(this, typeof option == 'object' && option); - $this.data('owl.carousel', data); - - $.each([ - 'next', 'prev', 'to', 'destroy', 'refresh', 'replace', 'add', 'remove' - ], function(i, event) { - data.register({ type: Owl.Type.Event, name: event }); - data.$element.on(event + '.owl.carousel.core', $.proxy(function(e) { - if (e.namespace && e.relatedTarget !== this) { - this.suppress([ event ]); - data[event].apply(this, [].slice.call(arguments, 1)); - this.release([ event ]); - } - }, data)); - }); - } - - if (typeof option == 'string' && option.charAt(0) !== '_') { - data[option].apply(data, args); - } - }); - }; - - /** - * The constructor for the jQuery Plugin - * @public - */ - $.fn.owlCarousel.Constructor = Owl; - -})(window.Zepto || window.jQuery, window, document); - -/** - * AutoRefresh Plugin - * @version 2.3.4 - * @author Artus Kolanowski - * @author David Deutsch - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - - /** - * Creates the auto refresh plugin. - * @class The Auto Refresh Plugin - * @param {Owl} carousel - The Owl Carousel - */ - var AutoRefresh = function(carousel) { - /** - * Reference to the core. - * @protected - * @type {Owl} - */ - this._core = carousel; - - /** - * Refresh interval. - * @protected - * @type {number} - */ - this._interval = null; - - /** - * Whether the element is currently visible or not. - * @protected - * @type {Boolean} - */ - this._visible = null; - - /** - * All event handlers. - * @protected - * @type {Object} - */ - this._handlers = { - 'initialized.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.autoRefresh) { - this.watch(); - } - }, this) - }; - - // set default options - this._core.options = $.extend({}, AutoRefresh.Defaults, this._core.options); - - // register event handlers - this._core.$element.on(this._handlers); - }; - - /** - * Default options. - * @public - */ - AutoRefresh.Defaults = { - autoRefresh: true, - autoRefreshInterval: 500 - }; - - /** - * Watches the element. - */ - AutoRefresh.prototype.watch = function() { - if (this._interval) { - return; - } - - this._visible = this._core.isVisible(); - this._interval = window.setInterval($.proxy(this.refresh, this), this._core.settings.autoRefreshInterval); - }; - - /** - * Refreshes the element. - */ - AutoRefresh.prototype.refresh = function() { - if (this._core.isVisible() === this._visible) { - return; - } - - this._visible = !this._visible; - - this._core.$element.toggleClass('owl-hidden', !this._visible); - - this._visible && (this._core.invalidate('width') && this._core.refresh()); - }; - - /** - * Destroys the plugin. - */ - AutoRefresh.prototype.destroy = function() { - var handler, property; - - window.clearInterval(this._interval); - - for (handler in this._handlers) { - this._core.$element.off(handler, this._handlers[handler]); - } - for (property in Object.getOwnPropertyNames(this)) { - typeof this[property] != 'function' && (this[property] = null); - } - }; - - $.fn.owlCarousel.Constructor.Plugins.AutoRefresh = AutoRefresh; - -})(window.Zepto || window.jQuery, window, document); - -/** - * Lazy Plugin - * @version 2.3.4 - * @author Bartosz Wojciechowski - * @author David Deutsch - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - - /** - * Creates the lazy plugin. - * @class The Lazy Plugin - * @param {Owl} carousel - The Owl Carousel - */ - var Lazy = function(carousel) { - - /** - * Reference to the core. - * @protected - * @type {Owl} - */ - this._core = carousel; - - /** - * Already loaded items. - * @protected - * @type {Array.} - */ - this._loaded = []; - - /** - * Event handlers. - * @protected - * @type {Object} - */ - this._handlers = { - 'initialized.owl.carousel change.owl.carousel resized.owl.carousel': $.proxy(function(e) { - if (!e.namespace) { - return; - } - - if (!this._core.settings || !this._core.settings.lazyLoad) { - return; - } - - if ((e.property && e.property.name == 'position') || e.type == 'initialized') { - var settings = this._core.settings, - n = (settings.center && Math.ceil(settings.items / 2) || settings.items), - i = ((settings.center && n * -1) || 0), - position = (e.property && e.property.value !== undefined ? e.property.value : this._core.current()) + i, - clones = this._core.clones().length, - load = $.proxy(function(i, v) { this.load(v) }, this); - //TODO: Need documentation for this new option - if (settings.lazyLoadEager > 0) { - n += settings.lazyLoadEager; - // If the carousel is looping also preload images that are to the "left" - if (settings.loop) { - position -= settings.lazyLoadEager; - n++; - } - } - - while (i++ < n) { - this.load(clones / 2 + this._core.relative(position)); - clones && $.each(this._core.clones(this._core.relative(position)), load); - position++; - } - } - }, this) - }; - - // set the default options - this._core.options = $.extend({}, Lazy.Defaults, this._core.options); - - // register event handler - this._core.$element.on(this._handlers); - }; - - /** - * Default options. - * @public - */ - Lazy.Defaults = { - lazyLoad: false, - lazyLoadEager: 0 - }; - - /** - * Loads all resources of an item at the specified position. - * @param {Number} position - The absolute position of the item. - * @protected - */ - Lazy.prototype.load = function(position) { - var $item = this._core.$stage.children().eq(position), - $elements = $item && $item.find('.owl-lazy'); - - if (!$elements || $.inArray($item.get(0), this._loaded) > -1) { - return; - } - - $elements.each($.proxy(function(index, element) { - var $element = $(element), image, - url = (window.devicePixelRatio > 1 && $element.attr('data-src-retina')) || $element.attr('data-src') || $element.attr('data-srcset'); - - this._core.trigger('load', { element: $element, url: url }, 'lazy'); - - if ($element.is('img')) { - $element.one('load.owl.lazy', $.proxy(function() { - $element.css('opacity', 1); - this._core.trigger('loaded', { element: $element, url: url }, 'lazy'); - }, this)).attr('src', url); - } else if ($element.is('source')) { - $element.one('load.owl.lazy', $.proxy(function() { - this._core.trigger('loaded', { element: $element, url: url }, 'lazy'); - }, this)).attr('srcset', url); - } else { - image = new Image(); - image.onload = $.proxy(function() { - $element.css({ - 'background-image': 'url("' + url + '")', - 'opacity': '1' - }); - this._core.trigger('loaded', { element: $element, url: url }, 'lazy'); - }, this); - image.src = url; - } - }, this)); - - this._loaded.push($item.get(0)); - }; - - /** - * Destroys the plugin. - * @public - */ - Lazy.prototype.destroy = function() { - var handler, property; - - for (handler in this.handlers) { - this._core.$element.off(handler, this.handlers[handler]); - } - for (property in Object.getOwnPropertyNames(this)) { - typeof this[property] != 'function' && (this[property] = null); - } - }; - - $.fn.owlCarousel.Constructor.Plugins.Lazy = Lazy; - -})(window.Zepto || window.jQuery, window, document); - -/** - * AutoHeight Plugin - * @version 2.3.4 - * @author Bartosz Wojciechowski - * @author David Deutsch - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - - /** - * Creates the auto height plugin. - * @class The Auto Height Plugin - * @param {Owl} carousel - The Owl Carousel - */ - var AutoHeight = function(carousel) { - /** - * Reference to the core. - * @protected - * @type {Owl} - */ - this._core = carousel; - - this._previousHeight = null; - - /** - * All event handlers. - * @protected - * @type {Object} - */ - this._handlers = { - 'initialized.owl.carousel refreshed.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.autoHeight) { - this.update(); - } - }, this), - 'changed.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.autoHeight && e.property.name === 'position'){ - this.update(); - } - }, this), - 'loaded.owl.lazy': $.proxy(function(e) { - if (e.namespace && this._core.settings.autoHeight - && e.element.closest('.' + this._core.settings.itemClass).index() === this._core.current()) { - this.update(); - } - }, this) - }; - - // set default options - this._core.options = $.extend({}, AutoHeight.Defaults, this._core.options); - - // register event handlers - this._core.$element.on(this._handlers); - this._intervalId = null; - var refThis = this; - - // These changes have been taken from a PR by gavrochelegnou proposed in #1575 - // and have been made compatible with the latest jQuery version - $(window).on('load', function() { - if (refThis._core.settings.autoHeight) { - refThis.update(); - } - }); - - // Autoresize the height of the carousel when window is resized - // When carousel has images, the height is dependent on the width - // and should also change on resize - $(window).resize(function() { - if (refThis._core.settings.autoHeight) { - if (refThis._intervalId != null) { - clearTimeout(refThis._intervalId); - } - - refThis._intervalId = setTimeout(function() { - refThis.update(); - }, 250); - } - }); - - }; - - /** - * Default options. - * @public - */ - AutoHeight.Defaults = { - autoHeight: false, - autoHeightClass: 'owl-height' - }; - - /** - * Updates the view. - */ - AutoHeight.prototype.update = function() { - var start = this._core._current, - end = start + this._core.settings.items, - lazyLoadEnabled = this._core.settings.lazyLoad, - visible = this._core.$stage.children().toArray().slice(start, end), - heights = [], - maxheight = 0; - - $.each(visible, function(index, item) { - heights.push($(item).height()); - }); - - maxheight = Math.max.apply(null, heights); - - if (maxheight <= 1 && lazyLoadEnabled && this._previousHeight) { - maxheight = this._previousHeight; - } - - this._previousHeight = maxheight; - - this._core.$stage.parent() - .height(maxheight) - .addClass(this._core.settings.autoHeightClass); - }; - - AutoHeight.prototype.destroy = function() { - var handler, property; - - for (handler in this._handlers) { - this._core.$element.off(handler, this._handlers[handler]); - } - for (property in Object.getOwnPropertyNames(this)) { - typeof this[property] !== 'function' && (this[property] = null); - } - }; - - $.fn.owlCarousel.Constructor.Plugins.AutoHeight = AutoHeight; - -})(window.Zepto || window.jQuery, window, document); - -/** - * Video Plugin - * @version 2.3.4 - * @author Bartosz Wojciechowski - * @author David Deutsch - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - - /** - * Creates the video plugin. - * @class The Video Plugin - * @param {Owl} carousel - The Owl Carousel - */ - var Video = function(carousel) { - /** - * Reference to the core. - * @protected - * @type {Owl} - */ - this._core = carousel; - - /** - * Cache all video URLs. - * @protected - * @type {Object} - */ - this._videos = {}; - - /** - * Current playing item. - * @protected - * @type {jQuery} - */ - this._playing = null; - - /** - * All event handlers. - * @todo The cloned content removale is too late - * @protected - * @type {Object} - */ - this._handlers = { - 'initialized.owl.carousel': $.proxy(function(e) { - if (e.namespace) { - this._core.register({ type: 'state', name: 'playing', tags: [ 'interacting' ] }); - } - }, this), - 'resize.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.video && this.isInFullScreen()) { - e.preventDefault(); - } - }, this), - 'refreshed.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.is('resizing')) { - this._core.$stage.find('.cloned .owl-video-frame').remove(); - } - }, this), - 'changed.owl.carousel': $.proxy(function(e) { - if (e.namespace && e.property.name === 'position' && this._playing) { - this.stop(); - } - }, this), - 'prepared.owl.carousel': $.proxy(function(e) { - if (!e.namespace) { - return; - } - - var $element = $(e.content).find('.owl-video'); - - if ($element.length) { - $element.css('display', 'none'); - this.fetch($element, $(e.content)); - } - }, this) - }; - - // set default options - this._core.options = $.extend({}, Video.Defaults, this._core.options); - - // register event handlers - this._core.$element.on(this._handlers); - - this._core.$element.on('click.owl.video', '.owl-video-play-icon', $.proxy(function(e) { - this.play(e); - }, this)); - }; - - /** - * Default options. - * @public - */ - Video.Defaults = { - video: false, - videoHeight: false, - videoWidth: false - }; - - /** - * Gets the video ID and the type (YouTube/Vimeo/vzaar only). - * @protected - * @param {jQuery} target - The target containing the video data. - * @param {jQuery} item - The item containing the video. - */ - Video.prototype.fetch = function(target, item) { - var type = (function() { - if (target.attr('data-vimeo-id')) { - return 'vimeo'; - } else if (target.attr('data-vzaar-id')) { - return 'vzaar' - } else { - return 'youtube'; - } - })(), - id = target.attr('data-vimeo-id') || target.attr('data-youtube-id') || target.attr('data-vzaar-id'), - width = target.attr('data-width') || this._core.settings.videoWidth, - height = target.attr('data-height') || this._core.settings.videoHeight, - url = target.attr('href'); - - if (url) { - - /* - Parses the id's out of the following urls (and probably more): - https://www.youtube.com/watch?v=:id - https://youtu.be/:id - https://vimeo.com/:id - https://vimeo.com/channels/:channel/:id - https://vimeo.com/groups/:group/videos/:id - https://app.vzaar.com/videos/:id - - Visual example: https://regexper.com/#(http%3A%7Chttps%3A%7C)%5C%2F%5C%2F(player.%7Cwww.%7Capp.)%3F(vimeo%5C.com%7Cyoutu(be%5C.com%7C%5C.be%7Cbe%5C.googleapis%5C.com)%7Cvzaar%5C.com)%5C%2F(video%5C%2F%7Cvideos%5C%2F%7Cembed%5C%2F%7Cchannels%5C%2F.%2B%5C%2F%7Cgroups%5C%2F.%2B%5C%2F%7Cwatch%5C%3Fv%3D%7Cv%5C%2F)%3F(%5BA-Za-z0-9._%25-%5D*)(%5C%26%5CS%2B)%3F - */ - - id = url.match(/(http:|https:|)\/\/(player.|www.|app.)?(vimeo\.com|youtu(be\.com|\.be|be\.googleapis\.com|be\-nocookie\.com)|vzaar\.com)\/(video\/|videos\/|embed\/|channels\/.+\/|groups\/.+\/|watch\?v=|v\/)?([A-Za-z0-9._%-]*)(\&\S+)?/); - - if (id[3].indexOf('youtu') > -1) { - type = 'youtube'; - } else if (id[3].indexOf('vimeo') > -1) { - type = 'vimeo'; - } else if (id[3].indexOf('vzaar') > -1) { - type = 'vzaar'; - } else { - throw new Error('Video URL not supported.'); - } - id = id[6]; - } else { - throw new Error('Missing video URL.'); - } - - this._videos[url] = { - type: type, - id: id, - width: width, - height: height - }; - - item.attr('data-video', url); - - this.thumbnail(target, this._videos[url]); - }; - - /** - * Creates video thumbnail. - * @protected - * @param {jQuery} target - The target containing the video data. - * @param {Object} info - The video info object. - * @see `fetch` - */ - Video.prototype.thumbnail = function(target, video) { - var tnLink, - icon, - path, - dimensions = video.width && video.height ? 'width:' + video.width + 'px;height:' + video.height + 'px;' : '', - customTn = target.find('img'), - srcType = 'src', - lazyClass = '', - settings = this._core.settings, - create = function(path) { - icon = '
'; - - if (settings.lazyLoad) { - tnLink = $('
',{ - "class": 'owl-video-tn ' + lazyClass, - "srcType": path - }); - } else { - tnLink = $( '
', { - "class": "owl-video-tn", - "style": 'opacity:1;background-image:url(' + path + ')' - }); - } - target.after(tnLink); - target.after(icon); - }; - - // wrap video content into owl-video-wrapper div - target.wrap( $( '
', { - "class": "owl-video-wrapper", - "style": dimensions - })); - - if (this._core.settings.lazyLoad) { - srcType = 'data-src'; - lazyClass = 'owl-lazy'; - } - - // custom thumbnail - if (customTn.length) { - create(customTn.attr(srcType)); - customTn.remove(); - return false; - } - - if (video.type === 'youtube') { - path = "//img.youtube.com/vi/" + video.id + "/hqdefault.jpg"; - create(path); - } else if (video.type === 'vimeo') { - $.ajax({ - type: 'GET', - url: '//vimeo.com/api/v2/video/' + video.id + '.json', - jsonp: 'callback', - dataType: 'jsonp', - success: function(data) { - path = data[0].thumbnail_large; - create(path); - } - }); - } else if (video.type === 'vzaar') { - $.ajax({ - type: 'GET', - url: '//vzaar.com/api/videos/' + video.id + '.json', - jsonp: 'callback', - dataType: 'jsonp', - success: function(data) { - path = data.framegrab_url; - create(path); - } - }); - } - }; - - /** - * Stops the current video. - * @public - */ - Video.prototype.stop = function() { - this._core.trigger('stop', null, 'video'); - this._playing.find('.owl-video-frame').remove(); - this._playing.removeClass('owl-video-playing'); - this._playing = null; - this._core.leave('playing'); - this._core.trigger('stopped', null, 'video'); - }; - - /** - * Starts the current video. - * @public - * @param {Event} event - The event arguments. - */ - Video.prototype.play = function(event) { - var target = $(event.target), - item = target.closest('.' + this._core.settings.itemClass), - video = this._videos[item.attr('data-video')], - width = video.width || '100%', - height = video.height || this._core.$stage.height(), - html, - iframe; - - if (this._playing) { - return; - } - - this._core.enter('playing'); - this._core.trigger('play', null, 'video'); - - item = this._core.items(this._core.relative(item.index())); - - this._core.reset(item.index()); - - html = $( '' ); - html.attr( 'height', height ); - html.attr( 'width', width ); - if (video.type === 'youtube') { - html.attr( 'src', '//www.youtube.com/embed/' + video.id + '?autoplay=1&rel=0&v=' + video.id ); - } else if (video.type === 'vimeo') { - html.attr( 'src', '//player.vimeo.com/video/' + video.id + '?autoplay=1' ); - } else if (video.type === 'vzaar') { - html.attr( 'src', '//view.vzaar.com/' + video.id + '/player?autoplay=true' ); - } - - iframe = $(html).wrap( '
' ).insertAfter(item.find('.owl-video')); - - this._playing = item.addClass('owl-video-playing'); - }; - - /** - * Checks whether an video is currently in full screen mode or not. - * @todo Bad style because looks like a readonly method but changes members. - * @protected - * @returns {Boolean} - */ - Video.prototype.isInFullScreen = function() { - var element = document.fullscreenElement || document.mozFullScreenElement || - document.webkitFullscreenElement; - - return element && $(element).parent().hasClass('owl-video-frame'); - }; - - /** - * Destroys the plugin. - */ - Video.prototype.destroy = function() { - var handler, property; - - this._core.$element.off('click.owl.video'); - - for (handler in this._handlers) { - this._core.$element.off(handler, this._handlers[handler]); - } - for (property in Object.getOwnPropertyNames(this)) { - typeof this[property] != 'function' && (this[property] = null); - } - }; - - $.fn.owlCarousel.Constructor.Plugins.Video = Video; - -})(window.Zepto || window.jQuery, window, document); - -/** - * Animate Plugin - * @version 2.3.4 - * @author Bartosz Wojciechowski - * @author David Deutsch - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - - /** - * Creates the animate plugin. - * @class The Navigation Plugin - * @param {Owl} scope - The Owl Carousel - */ - var Animate = function(scope) { - this.core = scope; - this.core.options = $.extend({}, Animate.Defaults, this.core.options); - this.swapping = true; - this.previous = undefined; - this.next = undefined; - - this.handlers = { - 'change.owl.carousel': $.proxy(function(e) { - if (e.namespace && e.property.name == 'position') { - this.previous = this.core.current(); - this.next = e.property.value; - } - }, this), - 'drag.owl.carousel dragged.owl.carousel translated.owl.carousel': $.proxy(function(e) { - if (e.namespace) { - this.swapping = e.type == 'translated'; - } - }, this), - 'translate.owl.carousel': $.proxy(function(e) { - if (e.namespace && this.swapping && (this.core.options.animateOut || this.core.options.animateIn)) { - this.swap(); - } - }, this) - }; - - this.core.$element.on(this.handlers); - }; - - /** - * Default options. - * @public - */ - Animate.Defaults = { - animateOut: false, - animateIn: false - }; - - /** - * Toggles the animation classes whenever an translations starts. - * @protected - * @returns {Boolean|undefined} - */ - Animate.prototype.swap = function() { - - if (this.core.settings.items !== 1) { - return; - } - - if (!$.support.animation || !$.support.transition) { - return; - } - - this.core.speed(0); - - var left, - clear = $.proxy(this.clear, this), - previous = this.core.$stage.children().eq(this.previous), - next = this.core.$stage.children().eq(this.next), - incoming = this.core.settings.animateIn, - outgoing = this.core.settings.animateOut; - - if (this.core.current() === this.previous) { - return; - } - - if (outgoing) { - left = this.core.coordinates(this.previous) - this.core.coordinates(this.next); - previous.one($.support.animation.end, clear) - .css( { 'left': left + 'px' } ) - .addClass('animated owl-animated-out') - .addClass(outgoing); - } - - if (incoming) { - next.one($.support.animation.end, clear) - .addClass('animated owl-animated-in') - .addClass(incoming); - } - }; - - Animate.prototype.clear = function(e) { - $(e.target).css( { 'left': '' } ) - .removeClass('animated owl-animated-out owl-animated-in') - .removeClass(this.core.settings.animateIn) - .removeClass(this.core.settings.animateOut); - this.core.onTransitionEnd(); - }; - - /** - * Destroys the plugin. - * @public - */ - Animate.prototype.destroy = function() { - var handler, property; - - for (handler in this.handlers) { - this.core.$element.off(handler, this.handlers[handler]); - } - for (property in Object.getOwnPropertyNames(this)) { - typeof this[property] != 'function' && (this[property] = null); - } - }; - - $.fn.owlCarousel.Constructor.Plugins.Animate = Animate; - -})(window.Zepto || window.jQuery, window, document); - -/** - * Autoplay Plugin - * @version 2.3.4 - * @author Bartosz Wojciechowski - * @author Artus Kolanowski - * @author David Deutsch - * @author Tom De Caluwé - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - - /** - * Creates the autoplay plugin. - * @class The Autoplay Plugin - * @param {Owl} scope - The Owl Carousel - */ - var Autoplay = function(carousel) { - /** - * Reference to the core. - * @protected - * @type {Owl} - */ - this._core = carousel; - - /** - * The autoplay timeout id. - * @type {Number} - */ - this._call = null; - - /** - * Depending on the state of the plugin, this variable contains either - * the start time of the timer or the current timer value if it's - * paused. Since we start in a paused state we initialize the timer - * value. - * @type {Number} - */ - this._time = 0; - - /** - * Stores the timeout currently used. - * @type {Number} - */ - this._timeout = 0; - - /** - * Indicates whenever the autoplay is paused. - * @type {Boolean} - */ - this._paused = true; - - /** - * All event handlers. - * @protected - * @type {Object} - */ - this._handlers = { - 'changed.owl.carousel': $.proxy(function(e) { - if (e.namespace && e.property.name === 'settings') { - if (this._core.settings.autoplay) { - this.play(); - } else { - this.stop(); - } - } else if (e.namespace && e.property.name === 'position' && this._paused) { - // Reset the timer. This code is triggered when the position - // of the carousel was changed through user interaction. - this._time = 0; - } - }, this), - 'initialized.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.autoplay) { - this.play(); - } - }, this), - 'play.owl.autoplay': $.proxy(function(e, t, s) { - if (e.namespace) { - this.play(t, s); - } - }, this), - 'stop.owl.autoplay': $.proxy(function(e) { - if (e.namespace) { - this.stop(); - } - }, this), - 'mouseover.owl.autoplay': $.proxy(function() { - if (this._core.settings.autoplayHoverPause && this._core.is('rotating')) { - this.pause(); - } - }, this), - 'mouseleave.owl.autoplay': $.proxy(function() { - if (this._core.settings.autoplayHoverPause && this._core.is('rotating')) { - this.play(); - } - }, this), - 'touchstart.owl.core': $.proxy(function() { - if (this._core.settings.autoplayHoverPause && this._core.is('rotating')) { - this.pause(); - } - }, this), - 'touchend.owl.core': $.proxy(function() { - if (this._core.settings.autoplayHoverPause) { - this.play(); - } - }, this) - }; - - // register event handlers - this._core.$element.on(this._handlers); - - // set default options - this._core.options = $.extend({}, Autoplay.Defaults, this._core.options); - }; - - /** - * Default options. - * @public - */ - Autoplay.Defaults = { - autoplay: false, - autoplayTimeout: 5000, - autoplayHoverPause: false, - autoplaySpeed: false - }; - - /** - * Transition to the next slide and set a timeout for the next transition. - * @private - * @param {Number} [speed] - The animation speed for the animations. - */ - Autoplay.prototype._next = function(speed) { - this._call = window.setTimeout( - $.proxy(this._next, this, speed), - this._timeout * (Math.round(this.read() / this._timeout) + 1) - this.read() - ); - - if (this._core.is('interacting') || document.hidden) { - return; - } - this._core.next(speed || this._core.settings.autoplaySpeed); - } - - /** - * Reads the current timer value when the timer is playing. - * @public - */ - Autoplay.prototype.read = function() { - return new Date().getTime() - this._time; - }; - - /** - * Starts the autoplay. - * @public - * @param {Number} [timeout] - The interval before the next animation starts. - * @param {Number} [speed] - The animation speed for the animations. - */ - Autoplay.prototype.play = function(timeout, speed) { - var elapsed; - - if (!this._core.is('rotating')) { - this._core.enter('rotating'); - } - - timeout = timeout || this._core.settings.autoplayTimeout; - - // Calculate the elapsed time since the last transition. If the carousel - // wasn't playing this calculation will yield zero. - elapsed = Math.min(this._time % (this._timeout || timeout), timeout); - - if (this._paused) { - // Start the clock. - this._time = this.read(); - this._paused = false; - } else { - // Clear the active timeout to allow replacement. - window.clearTimeout(this._call); - } - - // Adjust the origin of the timer to match the new timeout value. - this._time += this.read() % timeout - elapsed; - - this._timeout = timeout; - this._call = window.setTimeout($.proxy(this._next, this, speed), timeout - elapsed); - }; - - /** - * Stops the autoplay. - * @public - */ - Autoplay.prototype.stop = function() { - if (this._core.is('rotating')) { - // Reset the clock. - this._time = 0; - this._paused = true; - - window.clearTimeout(this._call); - this._core.leave('rotating'); - } - }; - - /** - * Pauses the autoplay. - * @public - */ - Autoplay.prototype.pause = function() { - if (this._core.is('rotating') && !this._paused) { - // Pause the clock. - this._time = this.read(); - this._paused = true; - - window.clearTimeout(this._call); - } - }; - - /** - * Destroys the plugin. - */ - Autoplay.prototype.destroy = function() { - var handler, property; - - this.stop(); - - for (handler in this._handlers) { - this._core.$element.off(handler, this._handlers[handler]); - } - for (property in Object.getOwnPropertyNames(this)) { - typeof this[property] != 'function' && (this[property] = null); - } - }; - - $.fn.owlCarousel.Constructor.Plugins.autoplay = Autoplay; - -})(window.Zepto || window.jQuery, window, document); - -/** - * Navigation Plugin - * @version 2.3.4 - * @author Artus Kolanowski - * @author David Deutsch - * @license The MIT License (MIT) - */ -;(function($, window, document, undefined) { - 'use strict'; - - /** - * Creates the navigation plugin. - * @class The Navigation Plugin - * @param {Owl} carousel - The Owl Carousel. - */ - var Navigation = function(carousel) { - /** - * Reference to the core. - * @protected - * @type {Owl} - */ - this._core = carousel; - - /** - * Indicates whether the plugin is initialized or not. - * @protected - * @type {Boolean} - */ - this._initialized = false; - - /** - * The current paging indexes. - * @protected - * @type {Array} - */ - this._pages = []; - - /** - * All DOM elements of the user interface. - * @protected - * @type {Object} - */ - this._controls = {}; - - /** - * Markup for an indicator. - * @protected - * @type {Array.} - */ - this._templates = []; - - /** - * The carousel element. - * @type {jQuery} - */ - this.$element = this._core.$element; - - /** - * Overridden methods of the carousel. - * @protected - * @type {Object} - */ - this._overrides = { - next: this._core.next, - prev: this._core.prev, - to: this._core.to - }; - - /** - * All event handlers. - * @protected - * @type {Object} - */ - this._handlers = { - 'prepared.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.dotsData) { - this._templates.push('
' + - $(e.content).find('[data-dot]').addBack('[data-dot]').attr('data-dot') + '
'); - } - }, this), - 'added.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.dotsData) { - this._templates.splice(e.position, 0, this._templates.pop()); - } - }, this), - 'remove.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._core.settings.dotsData) { - this._templates.splice(e.position, 1); - } - }, this), - 'changed.owl.carousel': $.proxy(function(e) { - if (e.namespace && e.property.name == 'position') { - this.draw(); - } - }, this), - 'initialized.owl.carousel': $.proxy(function(e) { - if (e.namespace && !this._initialized) { - this._core.trigger('initialize', null, 'navigation'); - this.initialize(); - this.update(); - this.draw(); - this._initialized = true; - this._core.trigger('initialized', null, 'navigation'); - } - }, this), - 'refreshed.owl.carousel': $.proxy(function(e) { - if (e.namespace && this._initialized) { - this._core.trigger('refresh', null, 'navigation'); - this.update(); - this.draw(); - this._core.trigger('refreshed', null, 'navigation'); - } - }, this) - }; - - // set default options - this._core.options = $.extend({}, Navigation.Defaults, this._core.options); - - // register event handlers - this.$element.on(this._handlers); - }; - - /** - * Default options. - * @public - * @todo Rename `slideBy` to `navBy` - */ - Navigation.Defaults = { - nav: false, - navText: [ - '', - '' - ], - navSpeed: false, - navElement: 'button type="button" role="presentation"', - navContainer: false, - navContainerClass: 'owl-nav', - navClass: [ - 'owl-prev', - 'owl-next' - ], - slideBy: 1, - dotClass: 'owl-dot', - dotsClass: 'owl-dots', - dots: true, - dotsEach: false, - dotsData: false, - dotsSpeed: false, - dotsContainer: false - }; - - /** - * Initializes the layout of the plugin and extends the carousel. - * @protected - */ - Navigation.prototype.initialize = function() { - var override, - settings = this._core.settings; - - // create DOM structure for relative navigation - this._controls.$relative = (settings.navContainer ? $(settings.navContainer) - : $('
').addClass(settings.navContainerClass).appendTo(this.$element)).addClass('disabled'); - - this._controls.$previous = $('<' + settings.navElement + '>') - .addClass(settings.navClass[0]) - .html(settings.navText[0]) - .prependTo(this._controls.$relative) - .on('click', $.proxy(function(e) { - this.prev(settings.navSpeed); - }, this)); - this._controls.$next = $('<' + settings.navElement + '>') - .addClass(settings.navClass[1]) - .html(settings.navText[1]) - .appendTo(this._controls.$relative) - .on('click', $.proxy(function(e) { - this.next(settings.navSpeed); - }, this)); - - // create DOM structure for absolute navigation - if (!settings.dotsData) { - this._templates = [ $('' + '', ); var toggle = mainNav.find('.menu-item-more'); @@ -98,7 +110,7 @@ function subMenu() { subMenu.on('animationend', function (e) { subMenu.removeClass( - 'animate__animated animate__bounceIn animate__zoomOut' + 'animate__animated animate__bounceIn animate__zoomOut', ); if (e.originalEvent.animationName == 'zoomOut') { subMenu.hide(); @@ -108,26 +120,108 @@ function subMenu() { } function featured() { - $('.featured-feed').owlCarousel({ - dots: false, - margin: 30, - nav: true, - navText: [ - '', - '', - ], - responsive: { - 0: { - items: 1, + var glideFeed; + + function initializeGlide() { + glideFeed = new Glide('.featured-feed', { + type: 'carousel', + autoplay: 3500, + perView: 1, + breakpoints: { + 576: { perView: 1 }, + 768: { perView: 1 }, + 992: { perView: 1 }, + 1200: { perView: 1 }, + 1400: { perView: 1 }, }, + }); + + $('.featured-prev').on('click', function () { + glideFeed.go('<'); + }); + + $('.featured-next').on('click', function () { + glideFeed.go('>'); + }); + + glideFeed.on(['mount.after', 'run'], function () { + glideFeed.update({ perView: 1 }); + }); + + glideFeed.mount(); + } + + function destroyGlide() { + if (glideFeed) { + glideFeed.destroy(); + } + } + + // Debounce function to limit the rate at which the resize function is called + function debounce(func, delay) { + var timer; + return function () { + var context = this; + var args = arguments; + clearTimeout(timer); + timer = setTimeout(function () { + func.apply(context, args); + }, delay); + }; + } + + // Initialize Glide after DOM is fully loaded + document.addEventListener('DOMContentLoaded', function () { + initializeGlide(); + }); + + // Debounced refresh of Glide on window resize + var debouncedResize = debounce(function () { + if (glideFeed) { + destroyGlide(); + initializeGlide(); + } + }, 200); + + // Attach the debounced function to the window resize event + $(window).on('resize', debouncedResize); +} + +featured(); + +function partners() { + if (body.find('.partners-feed').length === 0) { + return; + } + + var glideFeed = new Glide('.partners-feed', { + type: 'carousel', + autoplay: 3500, + perView: 4, + breakpoints: { 768: { - items: 2, + perView: 2, }, - 992: { - items: 3, + }, + }); + glideFeed.mount(); +} + +function featuredBy() { + if (body.find('.featuredby-feed').length === 0) { + return; + } + + var glideFeed = new Glide('.featuredby-feed', { + type: 'carousel', + perView: 3, + breakpoints: { + 768: { + perView: 2, }, }, }); + glideFeed.mount(); } function pagination() { @@ -150,22 +244,14 @@ function video() { } function gallery() { - var images = document.querySelectorAll('.kg-gallery-image img'); - images.forEach(function (image) { - var container = image.closest('.kg-gallery-image'); - var width = image.attributes.width.value; - var height = image.attributes.height.value; - var ratio = width / height; + const images = document.querySelectorAll('.kg-gallery-image img'); + images.forEach((image) => { + const container = image.closest('.kg-gallery-image'); + const width = image.attributes.width.value; + const height = image.attributes.height.value; + const ratio = width / height; container.style.flex = ratio + ' 1 0%'; }); - - pswp( - '.kg-gallery-container', - '.kg-gallery-image', - '.kg-gallery-image', - false, - true - ); } function table() { @@ -193,6 +279,7 @@ function table() { } } +/* function modal() { var modalOverlay = $('.modal-overlay'); var modal = $('.modal'); @@ -230,100 +317,248 @@ function modal() { }); } +function elasticSearch(query, callback) { + var baseUrl = 'https://advisorysg.ent.ap-southeast-1.aws.found.io'; + var engine = 'ghost'; + var searchOnlyKey = 'search-brya3eig6n5g9ybimkw3o9u3'; + + var searchReq = new XMLHttpRequest(); + var payload = { query: query }; + searchReq.open( + 'POST', + `${baseUrl}/api/as/v1/engines/${engine}/search.json`, + ); + searchReq.addEventListener('load', callback); + searchReq.setRequestHeader('Content-Type', 'application/json'); + searchReq.setRequestHeader('Authorization', `Bearer ${searchOnlyKey}`); + searchReq.send(JSON.stringify(payload)); +} + +var searchSelectionId = 0; +var searchListingLength = 0; +function categoriseResult(post) { + if (!post.url_path || !post.url_path.raw) { + return ['home']; + } + var dir1 = post.url_path.raw.split('/')[1]; + if (dir1.match(/^[0-9]*$/g)) { + var tags = ['post']; + if (post.title.raw.startsWith('Conversations with')) { + tags.push('conversations'); + } else if (post.title.raw.startsWith('Reflections with')) { + tags.push('reflections'); + } else if (post.title.raw.startsWith('Insights on')) { + tags.push('insights'); + } + return tags; + } else { + var page_type = dir1.toLowerCase(); + return ['page', page_type]; + } +} + function search() { var searchInput = $('.search-input'); var searchButton = $('.search-button'); var searchResult = $('.search-result'); - var includeContent = - typeof gh_search_content == 'undefined' || gh_search_content == true - ? true - : false; - - if (typeof gh_search_key == 'undefined' || gh_search_key == '') return; - var url = - siteUrl + - '/ghost/api/v3/content/posts/?key=' + - gh_search_key + - '&limit=all&fields=id,title,url,updated_at,visibility&order=updated_at%20desc'; - url += includeContent ? '&formats=plaintext' : ''; - var indexDump = JSON.parse(localStorage.getItem('dawn_search_index')); - var index; - - elasticlunr.clearStopWords(); - - localStorage.removeItem('dawn_index'); - localStorage.removeItem('dawn_last'); - - function update(data) { - data.posts.forEach(function (post) { - index.addDoc(post); - }); - - try { - localStorage.setItem('dawn_search_index', JSON.stringify(index)); - localStorage.setItem('dawn_search_last', data.posts[0].updated_at); - } catch (e) { - console.error( - 'Your browser local storage is full. Update your search settings following the instruction at https://github.com/TryGhost/Dawn#disable-content-search' - ); + var modalOverlay = $('.modal-overlay'); + var body = $('body,html'); + + // for search pagination + var searchNext = $('.search-next'); + var searchPrev = $('.search-prev'); + var pageResult = $('.page-result'); + var pageButtons = $('.buttons-hide'); + let focusOnFirst; + var currentPage = 1; + var postPerPage = 5; + var maxPages; + var prevInput = ''; + + searchInput.on('input', function (e) { + const searchValue = e.target.value; + if (searchValue != prevInput) { + currentPage = 1; } - } - - if (!indexDump) { - $.get(url, function (data) { - if (data.posts.length > 0) { - index = elasticlunr(function () { - this.addField('title'); - if (includeContent) { - this.addField('plaintext'); + if (searchValue != '') { + elasticSearch(searchValue, function () { + var data = JSON.parse(this.responseText); + var output = ''; + var pagination = ''; + var counter = 0; + + searchListingLength = data.results.length; + var firstPost = + searchListingLength != 0 + ? (currentPage - 1) * postPerPage + : -1; + var lastPost = + Math.min(currentPage * postPerPage, searchListingLength) - + 1; + + data.results.forEach((post, index) => { + if (counter >= firstPost && counter <= lastPost) { + var searchValueRegex = new RegExp( + `(${searchValue})`, + 'ig', + ); + var highlightedTitle = + post.title && post.title.raw + ? post.title.snippet + ? post.title.snippet + .replaceAll(``, ``) + .replaceAll(``, ``) + .trim() + : post.title.raw.replaceAll( + searchValueRegex, + `$1`, + ) + : ''; + + var highlightedDescription = + post.meta_description && post.meta_description.raw + ? post.meta_description.snippet + ? post.meta_description.snippet + .replaceAll(``, ``) + .replaceAll(``, ``) + .trim() + : post.meta_description.raw.replaceAll( + searchValueRegex, + `$1`, + ) + : ''; + var tooltipDescription = + post.meta_description && post.meta_description.raw + ? post.meta_description.raw + : ''; + var tagsOutput = categoriseResult(post) + .map( + (tag) => ` +
+ ${tag} +
`, + ) + .reduce((a, b) => a + b, ''); + + output += ``; } - this.setRef('id'); + counter += 1; }); + searchResult.html(output); + searchResult.show(); + searchSelectionId = -1; + + clearTimeout(focusOnFirst); + + focusOnFirst = setTimeout(() => { + if (searchListingLength == 0 || searchSelectionId >= 0) + return; + searchSelectionId = 0; + $(`#search-element-${searchSelectionId}`).focus(); + }, 500); + + pagination += `
+ + Showing + ${ + firstPost + 1 + } + to + ${lastPost + 1} + of + ${searchListingLength} + results. + +
`; + pageResult.html(pagination); + pageResult.show(); + pageButtons.show(); + + maxPages = Math.ceil(searchListingLength / postPerPage); + searchPrev.prop('disabled', currentPage <= 1); + searchNext.prop('disabled', currentPage >= maxPages); + }); + } else { + searchResult.hide(); + pageResult.hide(); + pageButtons.hide(); + } - update(data); - } - }); - } else { - index = elasticlunr.Index.load(indexDump); - - $.get( - url + - "&filter=updated_at:>'" + - localStorage - .getItem('dawn_search_last') - .replace(/\..*/, '') - .replace(/T/, ' ') + - "'", - function (data) { - if (data.posts.length > 0) { - update(data); - } - } - ); - } - - searchInput.on('keyup', function (e) { - var result = index.search(e.target.value, { expand: true }); - var output = ''; - - result.forEach(function (post) { - output += - ''; - }); - - searchResult.html(output); - - if (e.target.value.length > 0) { + if (searchValue.length > 0) { searchButton.addClass('search-button-clear'); } else { searchButton.removeClass('search-button-clear'); } + prevInput = searchValue; + }); + + body.on('keydown', function () { + if (modalOverlay.css('display') === 'none') return; + modalOverlay.focus(); + }); + + modalOverlay.on('keydown', function (e) { + if (searchListingLength === 0) { + searchInput.focus(); + return; + } + switch (e.key) { + case 'ArrowUp': + searchSelectionId = + searchSelectionId > -1 ? searchSelectionId - 1 : -1; + if (searchSelectionId === -1) { + searchInput.focus(); + break; + } + e.preventDefault(); + $(`#search-element-${searchSelectionId}`).focus(); + break; + case 'ArrowDown': + searchSelectionId = + searchSelectionId < searchListingLength + ? searchSelectionId + 1 + : searchSelectionId; + e.preventDefault(); + $(`#search-element-${searchSelectionId}`).focus(); + break; + case 'ArrowLeft': + //go back to top search bar + searchSelectionId = -1; + searchInput.focus(); + break; + case 'ArrowRight': + //go back to top search bar + searchSelectionId = -1; + searchInput.focus(); + break; + case 'Enter': + break; + default: + searchInput.focus(); + return; + } }); $('.search-form').on('submit', function (e) { @@ -333,9 +568,26 @@ function search() { searchButton.on('click', function () { if ($(this).hasClass('search-button-clear')) { searchInput.val('').focus().keyup(); + currentPage = 1; + searchInput.trigger('input'); + } + }); + + searchPrev.on('click', function () { + if (currentPage > 1) { + currentPage -= 1; + searchInput.trigger('input'); + } + }); + + searchNext.on('click', function () { + if (currentPage < maxPages) { + currentPage += 1; + searchInput.trigger('input'); } }); } +*/ function burger() { $('.burger').on('click', function () { @@ -343,91 +595,86 @@ function burger() { }); } -function pswp(container, element, trigger, caption, isGallery) { - var parseThumbnailElements = function (el) { - var items = [], - gridEl, - linkEl, - item; - - $(el) - .find(element) - .each(function (i, v) { - gridEl = $(v); - linkEl = gridEl.find(trigger); - - item = { - src: isGallery - ? gridEl.find('img').attr('src') - : linkEl.attr('href'), - w: 0, - h: 0, - }; - - if (caption && gridEl.find(caption).length) { - item.title = gridEl.find(caption).html(); - } - - items.push(item); - }); - - return items; - }; - - var openPhotoSwipe = function (index, galleryElement) { - var pswpElement = document.querySelectorAll('.pswp')[0], - gallery, - options, - items; - - items = parseThumbnailElements(galleryElement); - - options = { - closeOnScroll: false, - history: false, - index: index, - shareEl: false, - showAnimationDuration: 0, - showHideOpacity: true, - }; - - gallery = new PhotoSwipe( - pswpElement, - PhotoSwipeUIDefault, - items, - options - ); - gallery.listen('gettingData', function (index, item) { - if (item.w < 1 || item.h < 1) { - // unknown size - var img = new Image(); - img.onload = function () { - // will get size after load - item.w = this.width; // set image width - item.h = this.height; // set image height - gallery.updateSize(true); // reinit Items - }; - img.src = item.src; // let's download image - } - }); - gallery.init(); - }; +var pillColours = {}; +function colourTags() { + var getPillColour = (text) => + pillColours[text] ? pillColours[text] : 'bg-brand-light'; + var getPillTextColour = () => 'text-gray-800'; + $('.tag-element').each(function () { + $(this).toggleClass('bg-brand-light'); + $(this).toggleClass(getPillColour(this.innerText)); + $(this).toggleClass('text-gray-800'); + $(this).toggleClass(getPillTextColour()); + }); +} - var onThumbnailsClick = function (e) { - e.preventDefault(); +function contentDropdown() { + var element = document.getElementById('content-dropdown-enable'); + if (typeof element == 'undefined' || element == null) { + return; + } + $('div.single-content h2').each(function () { + var $header = $(this); + var $container = $(''); + var $nextElement = $header.next(); + var $curElement = $nextElement; + var $arrowIcon = $('\u25B8'); + while ( + $nextElement.length && + !$nextElement.is('h1') && + !$nextElement.is('h2') + ) { + $curElement = $nextElement; + $nextElement = $nextElement.next(); + $container.append($curElement); + } - var index = $(e.target) - .closest(container) - .find(element) - .index($(e.target).closest(element)); - var clickedGallery = $(e.target).closest(container); + if ($container.length > 0) { + // Add the dropdown-header class to the h2 element + $header.addClass('dropdown-header'); + $header.prepend($arrowIcon); + // Hide the entire container and insert it after h2 + $container.hide().insertAfter($header); + + // Add a click event listener to the h2 element to toggle the container + $header.on('click', function () { + $container.slideToggle(); + if ($arrowIcon.text() === '\u25B8') { + $arrowIcon.text('\u25B4'); + } else { + $arrowIcon.text('\u25B8'); + } + }); + } + }); +} - openPhotoSwipe(index, clickedGallery[0]); +function tagContentFilter() { + const context = new URLSearchParams(window.location.search).get('context'); + const tagContent = document.querySelector('.tag-content'); - return false; - }; + if (!tagContent) { + return; + } - $(container).on('click', trigger, function (e) { - onThumbnailsClick(e); + const posts = tagContent.querySelectorAll('.post'); + posts.forEach((post) => { + const postTags = post.getAttribute('class').split(' '); + if ( + context === 'events' && + !postTags.includes('tag-hash-insights') && + !postTags.includes('tag-hash-insight-2') + ) { + post.style.display = 'none'; + } else if ( + context === 'interviews' && + !postTags.includes('tag-hash-conversations') && + !postTags.includes('tag-hash-conversations-2') && + !postTags.includes('tag-hash-conversations-3') && + !postTags.includes('tag-hash-reflections') && + !postTags.includes('tag-hash-reflections-2') + ) { + post.style.display = 'none'; + } }); } diff --git a/assets/js/post-filter-list.js b/assets/js/post-filter-list.js new file mode 100644 index 00000000..5e95f311 --- /dev/null +++ b/assets/js/post-filter-list.js @@ -0,0 +1,373 @@ +import { searchSlugs } from './typesense-search.js'; +// Alpine component for client-side multi-tag filter, sort, search, load-more, and URL sync. +// Used by partials/post-filter-list.hbs on /events/ and /interviews/. +// +// Call site: +//
+// +// Parameters: +// collection: passed through for the partial's DOM id namespace; not used here. +// mode: 'or' (default) or 'and'. Selects matching function for chips. +// tagSlugs: comma-separated string of tag slugs scoping this collection. +// Parsed into an array and passed to searchSlugs() as the +// Typesense filter_by value so cross-collection slug results +// don't bleed into this page. +// Sort modes accepted in URL params and the dropdown UI. +// Keep this list in sync with the ` bound to `sortMode`. Search input is added to the same toolbar in Task 8. + +- [ ] **Step 1: Insert the toolbar block in `partials/post-filter-list.hbs`** + +Find the root `
` opening (line 7-10). Immediately after the opening `
` (line 10) and before the existing `{{!-- Filter chips: ... --}}` comment (line 12), insert: + +```handlebars +{{! Toolbar: sort dropdown (and search input — added in Task 8). }} +
+
+ + +
+
+``` + +Notes: + +- `x-cloak` hides the toolbar until Alpine inits (otherwise the dropdown would briefly show a stale value during URL-seeded loads). +- `justify-end` right-aligns the dropdown for now. Task 8 will change this to `justify-between` once the search input occupies the left side. + +- [ ] **Step 2: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 3: Manually verify in browser** + +Reload `http://localhost:2368/events/`. The sort dropdown should appear above the chip row, right-aligned. Changing it should reorder the cards. Reload `?sort=az` should pre-select "Title A → Z" and show alphabetical order. + +Same on `http://localhost:2368/interviews/`. + +- [ ] **Step 4: Commit** + +``` +git add partials/post-filter-list.hbs +git commit -m "feat(filter): add sort dropdown to toolbar" +``` + +--- + +## Task 5: Create `assets/js/typesense-search.js` wrapper + +**Files:** + +- Create: `assets/js/typesense-search.js` + +Self-contained module. Three constants at the top (host, key, collection), one exported function. No state. The Alpine component will own the AbortController; this module just accepts the signal and passes it to `fetch`. + +- [ ] **Step 1: Create `assets/js/typesense-search.js`** + +```javascript +// Wrapper around Typesense's search REST API for the post-filter-list component. +// +// The TYPESENSE_API_KEY below is a *search-only* key — Typesense's analogue of +// Ghost's Content API key. It is read-only and scoped to the `ghost` collection. +// Embedding it client-side is intentional and safe (Typesense convention). +// +// Schema verified live on 2026-05-06: collection holds 283 documents with +// fields title, slug, excerpt, plaintext, tags.slug, published_at, etc. + +const TYPESENSE_HOST = "https://typesense.advisory.sg"; +const TYPESENSE_API_KEY = "LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g"; +const TYPESENSE_COLLECTION = "ghost"; + +// Fields and weights — see spec. +const QUERY_BY = "title,excerpt,plaintext"; +const QUERY_BY_WEIGHTS = "4,2,1"; +const PER_PAGE = 250; + +/** + * Search the `ghost` collection and return matching post slugs. + * + * @param {string} query - Trimmed user query. Caller must ensure length >= 2. + * @param {string[]} tagSlugs - Tag slugs scoping the search (the page's collection tags). + * If empty, no filter_by is sent. + * @param {AbortSignal} [signal] - Aborts the request if the caller's controller fires. + * @returns {Promise>} Resolves with the set of matching post slugs. + * @throws {Error} on network failure, non-2xx response, or malformed JSON. + * AbortError is propagated as-is so callers can distinguish. + */ +export async function searchSlugs(query, tagSlugs, signal) { + const params = new URLSearchParams({ + q: query, + query_by: QUERY_BY, + query_by_weights: QUERY_BY_WEIGHTS, + include_fields: "slug", + per_page: String(PER_PAGE), + }); + if (tagSlugs && tagSlugs.length > 0) { + params.set("filter_by", `tags.slug:[${tagSlugs.join(",")}]`); + } + + const url = `${TYPESENSE_HOST}/collections/${TYPESENSE_COLLECTION}/documents/search?${params.toString()}`; + const response = await fetch(url, { + headers: { "X-TYPESENSE-API-KEY": TYPESENSE_API_KEY }, + signal, + }); + if (!response.ok) { + throw new Error(`Typesense HTTP ${response.status}`); + } + const data = await response.json(); + const slugs = (data.hits || []).map((h) => h.document.slug); + return new Set(slugs); +} +``` + +- [ ] **Step 2: Build and lint** + +``` +npx gulp build +npm run test +``` + +The build should bundle the new file into `assets/built/main.js` only once it's imported (Task 7). For now it just compiles standalone via webpack's module resolution. + +- [ ] **Step 3: Manually verify in browser (DevTools console)** + +Open `http://localhost:2368/events/`. Open DevTools → Console. Paste a one-off probe (the function isn't yet imported anywhere, so we test via direct fetch using the same shape): + +```javascript +fetch( + "https://typesense.advisory.sg/collections/ghost/documents/search?q=stoic&query_by=title,excerpt,plaintext&query_by_weights=4,2,1&include_fields=slug&per_page=250", + { + headers: { "X-TYPESENSE-API-KEY": "LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g" }, + }, +) + .then((r) => r.json()) + .then((d) => + console.log( + "found", + d.found, + "sample slugs:", + d.hits.slice(0, 3).map((h) => h.document.slug), + ), + ); +``` + +Expected: a `found` count and 0-3 slug strings logged. CORS preflight must succeed; if it fails, Typesense host's CORS config is the issue (operational, fix outside this plan). + +- [ ] **Step 4: Commit** + +``` +git add assets/js/typesense-search.js +git commit -m "feat(search): add Typesense search wrapper" +``` + +--- + +## Task 6: Thread `tagSlugs` from page templates into the partial and component + +**Files:** + +- Modify: `partials/post-filter-list.hbs:7-10` (root `
` `x-data` attribute) +- Modify: `events.hbs` (the `{{> post-filter-list ...}}` call) +- Modify: `interviews.hbs` (the `{{> post-filter-list ...}}` call) + +The Typesense collection holds _all_ Ghost posts. To prevent search results bleeding across collections, every search request must carry `filter_by=tags.slug:[]`. The slug list comes from `routes.yaml` and is duplicated into each page template as a `tagSlugs` parameter, then threaded through the partial into the Alpine component (which already accepts `tagSlugs` after Task 3). + +- [ ] **Step 1: Update `partials/post-filter-list.hbs` root to pass `tagSlugs` into Alpine** + +Find lines 7-10 (currently): + +```handlebars +
+``` + +Replace with: + +```handlebars +
+``` + +- [ ] **Step 2: Update `events.hbs` partial call** + +Find the line: + +```handlebars +{{> post-filter-list collection="events" filter="tag:[hash-insights,hash-insights-2]" mode="or"}} +``` + +Replace with: + +```handlebars +{{> post-filter-list collection="events" filter="tag:[hash-insights,hash-insights-2]" tagSlugs="hash-insights,hash-insights-2" mode="or"}} +``` + +- [ ] **Step 3: Update `interviews.hbs` partial call** + +Find the line: + +```handlebars +{{> post-filter-list collection="interviews" filter="tag:[hash-conversations,hash-conversations-2,hash-conversations-3,hash-reflections,hash-reflections-2]" mode="or"}} +``` + +Replace with: + +```handlebars +{{> post-filter-list collection="interviews" filter="tag:[hash-conversations,hash-conversations-2,hash-conversations-3,hash-reflections,hash-reflections-2]" tagSlugs="hash-conversations,hash-conversations-2,hash-conversations-3,hash-reflections,hash-reflections-2" mode="or"}} +``` + +(Yes, the slug list appears twice in each call — once for Ghost's `{{#get}}` filter expression, once as a plain comma-separated string for Typesense. Source of truth remains `routes.yaml`.) + +- [ ] **Step 4: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 5: Manually verify in browser (DevTools console)** + +Reload `http://localhost:2368/events/`. Console: + +```javascript +const c = Alpine.$data(document.querySelector('[x-data^="postFilterList"]')); +console.log(c.tagSlugs); +// Should print: ['hash-insights', 'hash-insights-2'] +``` + +Same on interviews — should print the 5-element array. + +- [ ] **Step 6: Commit** + +``` +git add partials/post-filter-list.hbs events.hbs interviews.hbs +git commit -m "feat(filter): thread page tagSlugs into Alpine component for search scoping" +``` + +--- + +## Task 7: Search state, debounced fetch, AbortController, integration into filter pipeline + +**Files:** + +- Modify: `assets/js/post-filter-list.js` (add import; extend state, init, filtered, clearFilters; add `_runSearch`, `setSearchQuery`; extend URL state) + +Wire the Typesense wrapper into the component. UI for the search input lands in Task 8 — for now the state is testable from the console. + +- [ ] **Step 1: Add the import at the top of `assets/js/post-filter-list.js`** + +At the very top of the file, before the existing comment header, add: + +```javascript +import { searchSlugs } from "./typesense-search.js"; +``` + +- [ ] **Step 2: Extend the returned state object** + +Inside the `return { ... }` block, find the `--- state ---` section. After `tagSlugs: ...,` add: + +```javascript + searchQuery: '', + searchSlugs: null, // null = no search active or query < 2 chars; Set = Typesense results + searchError: false, + isSearching: false, + _searchAbortController: null, + _searchDebounceTimer: null, +``` + +The two underscore-prefixed fields are non-reactive runtime handles (Alpine will still proxy them, but we never bind to them in templates). Keeping them on the component avoids module-level state. + +- [ ] **Step 3: Extend `init()` to seed search from URL and watch `searchQuery`** + +Replace the existing `init()` body with: + +```javascript + init() { + this.allCards = this._readCardsFromDom(); + this.availableTags = this._buildAvailableTags(this.allCards); + + const state = this._readStateFromUrl(); + this.selectedTags = state.tags; + this.sortMode = state.sort; + this.searchQuery = state.q; + + this.$watch('selectedTags', () => { + this.visibleCount = PAGE_SIZE; + this._writeStateToUrl(); + }); + this.$watch('sortMode', () => { + this.visibleCount = PAGE_SIZE; + this._reorderDom(); + this._writeStateToUrl(); + }); + + // Apply initial DOM order if URL specified a non-default sort. + if (this.sortMode !== 'newest') { + this._reorderDom(); + } + + // Fire a search if URL had ?q=… on load. + if (this.searchQuery.trim().length >= 2) { + this._runSearch(); + } + }, +``` + +- [ ] **Step 4: Update `filtered()` to apply the search filter** + +Replace the existing `filtered()` body with: + +```javascript + filtered() { + let result = this.allCards.filter((c) => this.matches(c)); + if (this.searchSlugs !== null) { + result = result.filter((c) => this.searchSlugs.has(c.slug)); + } + return this._sorted(result); + }, +``` + +- [ ] **Step 5: Update `clearFilters()` to reset all state** + +Replace the existing `clearFilters()` body with: + +```javascript + clearFilters() { + this.selectedTags = []; + this.sortMode = 'newest'; + this.searchQuery = ''; + this.searchSlugs = null; + this.searchError = false; + }, +``` + +- [ ] **Step 6: Add `setSearchQuery` and `_runSearch` to the component** + +Inside the `--- actions ---` section, before `clearFilters`, add: + +```javascript + setSearchQuery(value) { + this.searchQuery = value; + clearTimeout(this._searchDebounceTimer); + this._searchDebounceTimer = setTimeout(() => { + this._runSearch(); + }, 250); + }, +``` + +Inside the `--- internals ---` section, after `_reorderDom()`, add: + +```javascript + async _runSearch() { + const trimmed = this.searchQuery.trim(); + + // Cancel any in-flight request — newer query supersedes it. + if (this._searchAbortController) { + this._searchAbortController.abort(); + } + + // Short-circuit: <2 chars means no search active. + if (trimmed.length < 2) { + this.searchSlugs = null; + this.searchError = false; + this.isSearching = false; + this.visibleCount = PAGE_SIZE; + this._writeStateToUrl(); + return; + } + + this._searchAbortController = new AbortController(); + this.isSearching = true; + + try { + const slugs = await searchSlugs( + trimmed, + this.tagSlugs, + this._searchAbortController.signal, + ); + this.searchSlugs = slugs; + this.searchError = false; + this.visibleCount = PAGE_SIZE; + this._writeStateToUrl(); + } catch (err) { + if (err.name === 'AbortError') return; // newer query is in flight + this.searchSlugs = null; + this.searchError = true; + } finally { + this.isSearching = false; + } + }, +``` + +- [ ] **Step 7: Extend `_readStateFromUrl` and `_writeStateToUrl` to handle `q`** + +Replace the existing `_readStateFromUrl` body with: + +```javascript + _readStateFromUrl() { + const params = new URLSearchParams(window.location.search); + + const tags = (params.get('tags') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const rawSort = params.get('sort') || 'newest'; + const validSorts = ['newest', 'oldest', 'az', 'za']; + const sort = validSorts.includes(rawSort) ? rawSort : 'newest'; + + const q = params.get('q') || ''; + + return { tags, sort, q }; + }, +``` + +Replace the existing `_writeStateToUrl` body with: + +```javascript + _writeStateToUrl() { + const params = new URLSearchParams(window.location.search); + + if (this.selectedTags.length) { + params.set('tags', this.selectedTags.join(',')); + } else { + params.delete('tags'); + } + + if (this.sortMode && this.sortMode !== 'newest') { + params.set('sort', this.sortMode); + } else { + params.delete('sort'); + } + + const q = (this.searchQuery || '').trim(); + if (q.length >= 2) { + params.set('q', q); + } else { + params.delete('q'); + } + + const qs = params.toString(); + const newUrl = qs + ? `${window.location.pathname}?${qs}` + : window.location.pathname; + window.history.replaceState(null, '', newUrl); + }, +``` + +- [ ] **Step 8: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 9: Manually verify in browser (DevTools console)** + +Reload `http://localhost:2368/events/`. Console: + +```javascript +const c = Alpine.$data(document.querySelector('[x-data^="postFilterList"]')); + +// Trigger a search programmatically (simulates UI input). +c.setSearchQuery("stoic"); + +// Wait ~500ms then inspect. +setTimeout(() => { + console.log( + "searchSlugs size:", + c.searchSlugs?.size, + "isSearching:", + c.isSearching, + "searchError:", + c.searchError, + ); + console.log("visible count:", c.visible().length); + console.log("URL:", location.search); +}, 600); +``` + +Expected: + +- `searchSlugs` is a `Set` with 0+ entries. +- `isSearching` is `false` (request completed). +- `visible count` reflects the intersection of search results with loaded cards. +- URL has `?q=stoic`. + +Reload `http://localhost:2368/events/?q=banking`. Console — search should fire on init, narrow results. + +Test the abort path: + +```javascript +c.setSearchQuery("a"); // <2 chars → searchSlugs = null +c.setSearchQuery("ab"); // fires search +c.setSearchQuery("abc"); // aborts the prior, fires new +``` + +Network panel: only the latest request should complete; earlier ones show `cancelled` status. + +Test error path: in DevTools → Network, right-click → Block request URL on the Typesense host. Then `c.setSearchQuery('test')`. Wait. `c.searchError` should become `true`. Unblock URL. + +- [ ] **Step 10: Commit** + +``` +git add assets/js/post-filter-list.js +git commit -m "feat(search): integrate Typesense search into filter pipeline" +``` + +--- + +## Task 8: Search input UI, searching indicator, error banner, empty-state copy, Clear-filters scope + +**Files:** + +- Modify: `partials/post-filter-list.hbs` (toolbar block, chip-row Clear button, empty state, error banner) + +Add the search input to the toolbar (left side, flex-1) alongside the existing sort dropdown (right side). Add a `Searching…` indicator. Add a Typesense-error banner. Adapt the empty-state copy to mention search. Extend the Clear-filters button visibility to also trigger when search or sort is active. + +- [ ] **Step 1: Replace the toolbar block in `partials/post-filter-list.hbs`** + +Find the toolbar block added in Task 4: + +```handlebars +{{! Toolbar: sort dropdown (and search input — added in Task 8). }} +
+
+ + +
+
+``` + +Replace with: + +```handlebars +{{! Toolbar: search input (left, flex-1) + sort dropdown (right). }} +
+
+ + + + Searching… + +
+
+ + +
+
+ +{{! Search error banner }} + +``` + +- [ ] **Step 2: Extend the chip-row Clear-filters button visibility** + +Find the existing chip-row Clear-filters button (currently inside the `filter-chips` div): + +```handlebars + +``` + +Replace the `x-show` attribute: + +```handlebars + +``` + +- [ ] **Step 3: Adapt the empty-state copy** + +Find the empty state block: + +```handlebars +{{! Empty state }} +
+

No posts match your selected tags.

+ +
+``` + +Replace the entire block with: + +```handlebars +{{! Empty state }} +
+

No + posts match your search and filters.

+

No + posts match your search.

+

No + posts match your selected tags.

+

No + posts to show.

+ +
+``` + +- [ ] **Step 4: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 5: Manually verify in browser** + +Reload `http://localhost:2368/events/`. + +1. Toolbar shows search input (left, full-width) and sort dropdown (right). +2. Type a query — results live-narrow after ~250ms; "Searching…" appears briefly. +3. Combine with a tag chip — narrows further (intersection). +4. Clear search — full set returns. +5. Type a nonsense query like "asdfqwerzxcv" — empty state shows "No posts match your search." with Clear filters button. +6. Click Clear filters — search input empties, chips deselect, sort returns to Newest first. +7. Press Esc inside the search input — clears the input and refocuses it. +8. Reload `?q=banking&sort=oldest&tags=hash-insights` — all three apply correctly on initial paint. +9. (Mobile) Resize viewport to 375px — toolbar wraps: search above, sort below. Both still functional. +10. (Error path) DevTools → Network → block `typesense.advisory.sg`. Type a search. Red error banner appears: "Search temporarily unavailable…". Chips and sort still work. Unblock and clear search to dismiss banner. + +Same checks on `/interviews/`. + +- [ ] **Step 6: Commit** + +``` +git add partials/post-filter-list.hbs +git commit -m "feat(search): add search input, indicator, banner, and empty-state adaptation" +``` + +--- + +## Task 9: End-to-end manual verification matrix + +**Files:** none (verification only) + +A final pass against the spec's testing checklist (section 12 of the spec). No code changes. Catch anything missed before handing back to the user. + +- [ ] **Step 1: Run the full matrix on `/events/`** + +Walk through every scenario in `docs/superpowers/specs/2026-05-06-search-sort-fullload-design.md` § "Testing and verification": + +1. ✅ Page loads → toolbar visible, chips visible, full event-post count present in DOM (`document.querySelectorAll('.filter-card-wrapper').length` matches expected). +2. ✅ Type "stoic" → live narrow, count updates, URL gets `?q=stoic`. +3. ✅ Click an interviews-related chip + search → AND-narrow. +4. ✅ Change sort to "Title A→Z" → cards visually reorder. URL has `sort=az`. +5. ✅ Refresh `/events/?q=banking&tags=hash-insights&sort=oldest` → all three pre-applied correctly on first paint. +6. ✅ Block Typesense host → banner appears on next search; chips and sort still work. +7. ✅ Type "x" (one char) → no Typesense request fires (verify in Network panel). Type "xy" → request fires. +8. ✅ Type fast (multiple chars rapidly) → in-flight requests get cancelled (`status: cancelled` in Network panel). +9. ✅ Disable JavaScript in DevTools → all posts visible in static grid; no toolbar; no chip UI. +10. ✅ Mobile viewport (375px) → toolbar wraps to two rows. +11. ✅ Tab through controls with keyboard. Tab order: search → sort → first chip → other chips → first card → load-more (if visible). +12. ✅ Esc inside search input clears + refocuses. +13. ✅ Clear filters button (in chip row AND in empty state) clears all four dimensions: tags, search, sort. + +- [ ] **Step 2: Run the same matrix on `/interviews/`** + +Identical checks; substitute interview tag slugs. + +- [ ] **Step 3: Confirm `/posts/` is unaffected** + +Load `http://localhost:2368/posts/`. Should show plain post grid, no toolbar, no chips, title "Posts". This template (`index.hbs`) shouldn't have changed. + +- [ ] **Step 4: Confirm tag pages are unaffected** + +Load `http://localhost:2368/tag//`. Should show normal tag page, no filter UI. (Reads from `tag.hbs`, not touched.) + +- [ ] **Step 5: Run `gscan` one final time** + +``` +npm run test +``` + +Should pass with no errors. + +- [ ] **Step 6: Hand back to user** + +Report: + +- Total commits added on this branch since the start of the plan. +- Any scenario from the matrix that didn't behave as expected. +- Any surprises or deviations from the spec that the implementer made. +- Suggested next step (typically: invoke `superpowers:finishing-a-development-branch` for PR/merge). + +--- + +## Self-review notes (writer's check) + +**Spec coverage check:** + +- Full-corpus load (Approach A, 20 blocks, 2000 ceiling) → Task 1. +- Sort options + UI + URL sync → Tasks 2, 3, 4. +- Typesense search wrapper → Task 5. +- `tagSlugs` plumbing for `filter_by` → Task 6. +- Search state, debounce, AbortController, error handling → Task 7. +- Search UI, searching indicator, error banner, empty-state copy, Clear-filters scope → Task 8. +- Accessibility (sr-only label, aria-controls, aria-live, role=alert, native select) → Task 8. +- All scenarios in spec § "Testing and verification" → Task 9. + +**Type/name consistency check:** + +- `_readStateFromUrl` / `_writeStateToUrl` introduced in Task 3, extended in Task 7. Same names throughout. +- `searchSlugs` (component state) vs `searchSlugs` (imported function from `typesense-search.js`) — same name, different scope. Module export is namespaced via `import { searchSlugs } from './typesense-search.js'` and called as `searchSlugs(...)` (function call), distinct from `this.searchSlugs` (property access). No actual collision but worth knowing. +- `tagSlugs` parameter (string from partial) vs `tagSlugs` state (parsed array on the component) — same name, parsed in the component constructor. Confirmed in Task 3, Step 1 ("comma-separated string of tag slugs … Not consumed in Task 3" → parsed in `tagSlugs: (tagSlugs || '').split(',')...`). +- `setSearchQuery` (Task 7 step 6) is called from the input handler in Task 8 step 1 (`@input="setSearchQuery($event.target.value)"`). Names match. +- `data-published-at` (Task 2) → `inner.dataset.publishedAt` (Task 3, `_readCardsFromDom`). Camelcase auto-conversion is correct. diff --git a/docs/superpowers/plans/2026-05-06-sort-filter.md b/docs/superpowers/plans/2026-05-06-sort-filter.md new file mode 100644 index 00000000..dcf2ee0f --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-sort-filter.md @@ -0,0 +1,893 @@ +# Sort & Filter (Events / Interviews) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-place, multi-tag, client-side filter to `/events/` and `/interviews/`, with URL-synced state, "Load more" pagination, and a configurable OR/AND mode. + +**Architecture:** All matching posts are server-rendered into the page once via Ghost's `{{#get}}` helper. An Alpine.js component reads the post DOM, builds an available-tag list, watches a `selectedTags` array, and toggles `x-show` on each card to filter and paginate without re-fetching. Filter state syncs to `?tags=...` via `history.replaceState`. + +**Tech Stack:** Ghost theme (Handlebars partials + `routes.yaml`), Alpine.js (already a dep), Tailwind CSS (already a dep), webpack via gulp (already configured). No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-05-06-sort-filter-design.md` + +## Working environment + +- Local Ghost instance at `http://localhost:2368` with this theme installed. +- Build the theme one-shot: `npx gulp build` (compiles HBS, CSS, JS into `assets/built/`). +- Watch mode for active dev: `npm run dev` (runs `gulp` default — build + livereload + watch). Leave it running in a separate terminal. +- Theme linter: `npm run test` (runs `gscan .`). Must show no errors. CI form: `npm run test:ci`. +- Pre-commit hook (husky + pretty-quick) runs Prettier on staged files. Don't fight it — let it reformat. +- All tasks commit to the current branch (`filter-addition`). + +## File map + +**Created:** + +- `interviews.hbs` — page template for `/interviews/` (mirror of `index.hbs` using the new partial). +- `partials/post-filter-list.hbs` — reusable filter UI + post grid + load-more. +- `assets/js/post-filter-list.js` — Alpine component definition. + +**Modified:** + +- `partials/post-card.hbs` — add `data-tags` and `data-tag-names` to root `
`. +- `events.hbs` — swap tags-listing/foreach/pagination block for the new partial. +- `assets/js/main.js` — import the component file and register before `Alpine.start()`. +- `config/routes.yaml` — point `/interviews/` template at `interviews` instead of `index`. + +**Untouched:** + +- `index.hbs` — still serves `/posts/` as a generic collection. +- `partials/tags-listing.hbs` — left intact. +- `tag.hbs` — left intact. + +--- + +## Task 1: Add `data-tags` and `data-tag-names` to `post-card.hbs` + +**Files:** + +- Modify: `partials/post-card.hbs:1` + +This card partial is shared across many pages (homepage, related posts, listings, tag pages). Adding two extra `data-*` attributes is harmless everywhere else and is what the new filter component reads. + +- [ ] **Step 1: Edit the root `
` element** + +Change line 1 of `partials/post-card.hbs` from: + +```handlebars +
+``` + +to: + +```handlebars +
+``` + +The `visibility="public"` argument excludes Ghost's internal hash-prefixed routing tags (`#insights`, `#conversations`, etc.) so they don't surface as filter chips. + +- [ ] **Step 2: Build and lint** + +Run: + +``` +npx gulp build +npm run test +``` + +Both should complete with no errors. + +- [ ] **Step 3: Manually verify in browser** + +With `npm run dev` running and Ghost serving the theme, load `http://localhost:2368/events/` in a browser. Open DevTools, inspect any `.feed-card` element, and confirm both `data-tags="..."` and `data-tag-names="..."` are present. They should contain only public tag slugs/names (no `hash-...` entries). + +- [ ] **Step 4: Commit** + +``` +git add partials/post-card.hbs +git commit -m "feat(post-card): expose public tags via data attributes" +``` + +--- + +## Task 2: Add the Alpine component skeleton + +**Files:** + +- Create: `assets/js/post-filter-list.js` +- Modify: `assets/js/main.js:1-12` + +Land the component file with all methods stubbed/empty so we can wire it into `main.js`, build, and verify Alpine doesn't error before adding logic. + +- [ ] **Step 1: Create `assets/js/post-filter-list.js`** + +```javascript +// Alpine component for client-side multi-tag filter, load-more, and URL sync. +// Used by partials/post-filter-list.hbs on /events/ and /interviews/. +// +// Call site: +//
+export default function postFilterList({ collection, mode }) { + const PAGE_SIZE = 12; + + return { + selectedTags: [], + visibleCount: PAGE_SIZE, + allCards: [], + availableTags: [], + + init() { + this.allCards = this._readCardsFromDom(); + this.availableTags = this._buildAvailableTags(); + this.selectedTags = this._readTagsFromUrl(); + this.$watch("selectedTags", () => { + this.visibleCount = PAGE_SIZE; + this._writeTagsToUrl(); + }); + }, + + // --- queries ----------------------------------------------------- + + matches(card) { + if (this.selectedTags.length === 0) return true; + if (mode === "and") { + return this.selectedTags.every((t) => card.tagSlugs.has(t)); + } + return this.selectedTags.some((t) => card.tagSlugs.has(t)); + }, + + filtered() { + return this.allCards.filter((c) => this.matches(c)); + }, + + visible() { + return this.filtered().slice(0, this.visibleCount); + }, + + isVisible(el) { + return this.visible().some((c) => c.el === el); + }, + + unknownTags() { + const known = new Set(this.availableTags.map((t) => t.slug)); + return this.selectedTags.filter((s) => !known.has(s)); + }, + + hasMore() { + return this.filtered().length > this.visibleCount; + }, + + // --- actions ----------------------------------------------------- + + isSelected(slug) { + return this.selectedTags.includes(slug); + }, + + toggleTag(slug) { + if (this.isSelected(slug)) { + this.selectedTags = this.selectedTags.filter((s) => s !== slug); + } else { + this.selectedTags = [...this.selectedTags, slug]; + } + }, + + loadMore() { + this.visibleCount += PAGE_SIZE; + // Move focus to the first newly-revealed card for keyboard users. + this.$nextTick(() => { + const newIndex = this.visibleCount - PAGE_SIZE; + const card = this.filtered()[newIndex]; + if (card && card.el) card.el.focus(); + }); + }, + + clearFilters() { + this.selectedTags = []; + }, + + // --- internals --------------------------------------------------- + + _readCardsFromDom() { + const root = this.$root || this.$el; + const cards = root.querySelectorAll("[data-tags]"); + return Array.from(cards).map((el) => { + const slugs = (el.dataset.tags || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return { el, tagSlugs: new Set(slugs) }; + }); + }, + + _buildAvailableTags() { + const root = this.$root || this.$el; + const cards = root.querySelectorAll("[data-tags]"); + const map = new Map(); + cards.forEach((el) => { + const slugs = (el.dataset.tags || "").split(","); + const names = (el.dataset.tagNames || "").split(","); + slugs.forEach((slug, i) => { + const s = slug.trim(); + if (!s) return; + if (!map.has(s)) { + map.set(s, { slug: s, name: (names[i] || s).trim() }); + } + }); + }); + return Array.from(map.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + }, + + _readTagsFromUrl() { + const params = new URLSearchParams(window.location.search); + const raw = params.get("tags") || ""; + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + }, + + _writeTagsToUrl() { + const params = new URLSearchParams(window.location.search); + if (this.selectedTags.length) { + params.set("tags", this.selectedTags.join(",")); + } else { + params.delete("tags"); + } + const qs = params.toString(); + const newUrl = qs + ? `${window.location.pathname}?${qs}` + : window.location.pathname; + window.history.replaceState(null, "", newUrl); + }, + }; +} +``` + +- [ ] **Step 2: Wire it into `main.js`** + +Modify the top of `assets/js/main.js`. Find: + +```javascript +import "./jquery-global.js"; + +import InfiniteScroll from "infinite-scroll"; +import fitvids from "fitvids"; +import "lazysizes"; + +import Glide from "@glidejs/glide"; +import Alpine from "alpinejs"; +import "flowbite"; + +window.Alpine = Alpine; +Alpine.start(); +``` + +Replace with: + +```javascript +import "./jquery-global.js"; + +import InfiniteScroll from "infinite-scroll"; +import fitvids from "fitvids"; +import "lazysizes"; + +import Glide from "@glidejs/glide"; +import Alpine from "alpinejs"; +import "flowbite"; + +import postFilterList from "./post-filter-list.js"; + +window.Alpine = Alpine; +Alpine.data("postFilterList", postFilterList); +Alpine.start(); +``` + +Order matters: `Alpine.data(...)` must run before `Alpine.start()`. + +- [ ] **Step 3: Build** + +Run: + +``` +npx gulp build +``` + +Should complete with no webpack errors. + +- [ ] **Step 4: Verify Alpine still works** + +Reload `http://localhost:2368/events/` in a browser. Open DevTools console — there should be no Alpine errors. Other Alpine-driven widgets on the page (if any — flowbite uses Alpine) should still work normally. + +- [ ] **Step 5: Commit** + +``` +git add assets/js/post-filter-list.js assets/js/main.js +git commit -m "feat(js): add postFilterList Alpine component" +``` + +--- + +## Task 3: Create `partials/post-filter-list.hbs` and use it on `/events/` + +**Files:** + +- Create: `partials/post-filter-list.hbs` +- Modify: `events.hbs` (entire body) + +Render the partial with full markup but no chip interaction yet — just the post grid via `{{#get}}`. This is the first user-visible step: `/events/` should look almost identical to before, with a chip row added above and a "Load more" button below. + +- [ ] **Step 1: Create the partial** + +Create `partials/post-filter-list.hbs`: + +```handlebars +{{!-- Reusable filter UI + post grid + load-more. + Parameters: + collection — string label (used as DOM id namespace) + filter — Ghost filter string passed to {{#get "posts"}} + mode — "or" (default) or "and" --}} + +
+ + {{!-- Filter chips (only visible if there are tags to filter by) --}} +
+ + +
+ + {{!-- Live result count for screen readers --}} +
+ +
+ + {{!-- Unknown-tags banner (URL referenced tags not on this page) --}} +
+ The following tags don't exist on this page: + +
+ + {{!-- Empty state --}} +
+

No posts match your selected tags.

+
+ + {{!-- Server-rendered post grid. Cards stay in DOM; Alpine toggles x-show. --}} +
+ + {{#get "posts" limit="1000" filter=filter include="tags" order="published_at desc" as |posts|}} + + {{#foreach posts}} +
+ {{> "post-card"}} +
+ {{/foreach}} + {{/get}} +
+ + {{!-- Load more button --}} +
+ +
+
+ + +``` + +Note on `isVisible`: each card is wrapped in a `
`, and the wrapper's `x-show` calls `isVisible()` against the inner `[data-tags]` element (the `
` produced by `post-card.hbs`). + +- [ ] **Step 2: Update `events.hbs` to use the partial** + +Replace the entire contents of `events.hbs` with: + +```handlebars +{{!< default}} + +{{#contentFor "title"}}Events{{/contentFor}} + +
+
+
+

Events

+
+ {{> post-filter-list collection="events" filter="tag:hash-insights" mode="or"}} +
+
+``` + +- [ ] **Step 3: Build and lint** + +Run: + +``` +npx gulp build +npm run test +``` + +Both should complete with no errors. + +- [ ] **Step 4: Manual verification at `localhost:2368/events/`** + +In the browser: + +- The page should render with a row of pill chips at the top (one per tag found across event posts), a "Showing X of Y" line, and the same post grid as before. A "Load more" button appears below if there are more than 12 posts. +- Click a chip — nothing should happen yet. Why: the `x-show` on each card uses `isVisible($el.querySelector('[data-tags]'))`, which depends on `visible()`, which depends on `selectedTags` and `visibleCount`. **It will work** as soon as `selectedTags` changes via `toggleTag` — but visually verify that the chips render and that the initial 12-card limit is enforced. +- Verify in DevTools: every `.feed-card` `
` is wrapped in a `.filter-card-wrapper`, and only the first 12 wrappers are visible (the rest have `display: none` from Alpine's `x-show`). +- Click a chip — the page should now filter to posts with that tag. Click again to deselect. +- Click two chips — posts with **either** tag should show (OR mode default). +- The URL should update to `?tags=...` as you click. + +- [ ] **Step 5: Commit** + +``` +git add partials/post-filter-list.hbs events.hbs +git commit -m "feat(events): add multi-tag filter and load-more" +``` + +--- + +## Task 4: Verify URL sync end-to-end + +**Files:** None modified. This is a manual verification gate. + +The Alpine component already implements URL sync (Task 2) and the partial is wired up (Task 3). This task confirms the end-to-end behavior matches the spec. + +- [ ] **Step 1: Filter from a clean URL** + +- Visit `http://localhost:2368/events/` (no query). +- Click two chips. URL should become `http://localhost:2368/events/?tags=slug-1,slug-2` (with whatever slugs you picked). +- Click one chip again to deselect. URL should drop that slug. +- Click "Clear filters". URL should drop `?tags=` entirely (back to plain `/events/`). + +- [ ] **Step 2: Land on a pre-filtered URL** + +- Manually navigate to `http://localhost:2368/events/?tags=` in the address bar. +- The page should load with that chip already selected and posts filtered. + +- [ ] **Step 3: Land on an unknown-tag URL** + +- Navigate to `http://localhost:2368/events/?tags=marine-biology-no-such-tag`. +- Expected: amber banner listing `marine-biology-no-such-tag`, empty post grid, "Showing 0 of 0", and "Clear filters" button visible. +- Click "Clear filters". Banner disappears, all posts return. + +- [ ] **Step 4: Land on a mixed URL (one valid, one unknown)** + +- Navigate to `http://localhost:2368/events/?tags=,nonexistent-slug`. +- Expected: real-slug chip selected, posts filtered to that real tag, banner showing only `nonexistent-slug`. + +- [ ] **Step 5: Land on a malformed URL** + +- Navigate to `http://localhost:2368/events/?tags=,,,`. +- Expected: no error, no filter applied, no banner (the empties are stripped). + +- [ ] **Step 6: Browser back/forward** + +- From `/events/`, click a chip → URL changes via `replaceState`. +- Press browser Back. You should leave `/events/` entirely (not bounce between filter states), because we used `replaceState` not `pushState`. This is the intended behavior per the spec. + +- [ ] **Step 7: Commit (no code change — just a marker)** + +If everything passes, no code changes are needed. Skip the commit. If something fails, debug and re-run this task before continuing. + +--- + +## Task 5: Verify load-more behavior + +**Files:** None modified. Another manual verification gate. + +- [ ] **Step 1: Initial state** + +- Visit `http://localhost:2368/events/`. +- Verify exactly 12 posts are visible (or all of them if the collection has fewer than 12). +- "Load more" button appears only if there are more than 12 matching posts. + +- [ ] **Step 2: Click "Load more"** + +- Click the button. +- 12 more posts become visible (or all remaining, whichever is smaller). +- Counter updates: "Showing 24 of N" (or similar). +- Button hides when all matches are visible. + +- [ ] **Step 3: Filter resets count** + +- After loading more (e.g. 24 visible of 30), click any chip. +- Visible count should reset to 12 (of however many match the filter). +- The `visibleCount = PAGE_SIZE` line in the `$watch('selectedTags')` callback drives this. + +- [ ] **Step 4: Focus moves on load-more** + +- Tab through the page using the keyboard until focus is on "Load more". +- Press Enter or Space. +- Focus should jump to the first newly-revealed card (the 13th overall, or the 13th match if a filter is applied). Verify by pressing Tab again — focus should move from card 13 onward, not from the start of the page. + +- [ ] **Step 5: Commit (no code change)** + +If everything passes, skip the commit. If focus management or the count reset isn't working, re-read Task 2 step 1 (component code) and fix. + +--- + +## Task 6: Add and manually verify the AND mode + +**Files:** + +- Modify: `events.hbs` (one-character change for the duration of this task; will be reverted) + +The component already handles `mode === 'and'` (Task 2 step 1). This task verifies it works end-to-end. + +- [ ] **Step 1: Switch the mode in `events.hbs`** + +Change: + +```handlebars +{{> post-filter-list collection="events" filter="tag:hash-insights" mode="or"}} +``` + +to: + +```handlebars +{{> post-filter-list collection="events" filter="tag:hash-insights" mode="and"}} +``` + +- [ ] **Step 2: Build** + +``` +npx gulp build +``` + +- [ ] **Step 3: Manual verification** + +- Visit `http://localhost:2368/events/`. +- Click one chip. Same posts as OR mode (single-tag selection behaves identically in both modes). +- Click a second chip. Now only posts that have **both** selected tags should show. If no post has both, you should see the empty state ("No posts match your selected tags"). +- Deselect one chip. Filter relaxes to whatever the remaining chip alone matches. + +- [ ] **Step 4: Revert to OR** + +Change the `mode="and"` back to `mode="or"`. Build again. + +- [ ] **Step 5: Commit (no code change since we reverted)** + +If verification passed, skip. The `mode` parameter is now production-ready; production runs OR. + +--- + +## Task 7: Add `interviews.hbs` and update `routes.yaml` + +**Files:** + +- Create: `interviews.hbs` +- Modify: `config/routes.yaml:10-13` + +Now repeat the events setup for the interviews collection. Create a dedicated `interviews.hbs` template (so the filter UI does not bleed onto generic `/posts/` or other pages that share `index.hbs`). + +- [ ] **Step 1: Create `interviews.hbs`** + +```handlebars +{{!< default}} + +{{#contentFor "title"}}Interviews{{/contentFor}} + +
+
+
+

Interviews

+
+ {{> post-filter-list + collection="interviews" + filter="tag:[hash-conversations,hash-reflections]" + mode="or"}} +
+
+``` + +- [ ] **Step 2: Update `config/routes.yaml`** + +Find: + +```yaml +/interviews/: + permalink: /{year}/{month}/{day}/{slug}/ + template: index + filter: tag:[hash-conversations,hash-reflections] +``` + +Change `template: index` to `template: interviews`: + +```yaml +/interviews/: + permalink: /{year}/{month}/{day}/{slug}/ + template: interviews + filter: tag:[hash-conversations,hash-reflections] +``` + +- [ ] **Step 3: Reload routes in Ghost** + +Ghost loads `routes.yaml` from the active theme. If it doesn't pick up the change automatically, in the Ghost admin (`http://localhost:2368/ghost/`) go to **Settings → Labs → Routes**, download/upload the routes file, or restart Ghost. (Locally, restarting Ghost is the simplest reliable option.) + +- [ ] **Step 4: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 5: Manual verification** + +- Visit `http://localhost:2368/interviews/`. Verify chips appear, filtering works, load-more works, URL sync works (same scenarios as Tasks 4–5 but on interviews). +- Visit `http://localhost:2368/posts/` (the generic collection that still uses `index.hbs`). Verify **no filter UI** appears here. +- Visit `http://localhost:2368/tag//`. Verify **no filter UI** appears here either (still uses `tag.hbs`). + +- [ ] **Step 6: Commit** + +``` +git add interviews.hbs config/routes.yaml +git commit -m "feat(interviews): add filter using shared partial" +``` + +--- + +## Task 8: Add canonical link to event/interview templates + +**Files:** + +- Modify: `events.hbs`, `interviews.hbs` (only if `{{ghost_head}}` does not already emit a canonical) + +Filtered URLs (`?tags=...`) are content variants of the canonical pages. Canonical link tags tell search engines to dedupe. + +- [ ] **Step 1: Check what `{{ghost_head}}` emits** + +Visit `http://localhost:2368/events/?tags=test` and view source. Look in `` for an existing `` tag. + +- If a canonical tag is present and points to `/events/` (without `?tags=`): you're done. Skip to Step 3. +- If no canonical, or it points to the filtered URL: proceed to Step 2. + +- [ ] **Step 2: Add canonical to both templates** + +Add this inside the `{{#contentFor "title"}}...{{/contentFor}}` blocks, or as a separate `contentFor "head"` block if `default.hbs` supports it. If it doesn't, add directly inside the body of the template — browsers and crawlers also accept it inside `` though `` is preferred. + +In `events.hbs`, after the `{{#contentFor "title"}}` line: + +```handlebars +{{#contentFor "head"}} + +{{/contentFor}} +``` + +In `interviews.hbs`, similarly: + +```handlebars +{{#contentFor "head"}} + +{{/contentFor}} +``` + +Then check `default.hbs` — if it does not have `{{{block "head"}}}` inside ``, add it just before `{{ghost_head}}`: + +```handlebars +{{{block "head"}}} +{{ghost_head}} +``` + +- [ ] **Step 3: Build and verify** + +``` +npx gulp build +``` + +Visit `http://localhost:2368/events/?tags=test` and view source. Confirm a single canonical pointing to `/events/` (not `/events/?tags=test`). + +- [ ] **Step 4: Commit (only if you made changes)** + +``` +git add events.hbs interviews.hbs default.hbs +git commit -m "seo: canonicalize filtered events/interviews URLs" +``` + +--- + +## Task 9: Accessibility manual verification + +**Files:** None modified, unless issues are found. + +The component already includes `role="checkbox"`, `aria-checked`, `aria-live`, and focus management. This task spot-checks them. + +- [ ] **Step 1: Keyboard tab order** + +- Visit `http://localhost:2368/events/`. +- From the address bar, press Tab until focus enters the chip row. +- Verify each chip is reachable and visible focus indicator appears. +- Press Space on a chip — it should toggle, posts should filter, screen reader (if available) should announce the new "Showing X of Y" count via the `aria-live` region. + +- [ ] **Step 2: Screen reader spot check (optional, if available)** + +- Enable VoiceOver (macOS), NVDA (Windows), or Orca (Linux). +- Tab to a chip — should announce "checkbox, not checked" (or similar). +- Activate it — should announce the updated result count. + +- [ ] **Step 3: Contrast and visual focus** + +- Tab through chips. Each focused chip should have a visible focus ring (browser default is fine; Tailwind doesn't strip it unless explicitly told to). +- Selected vs unselected chips should differ in **more than just color** (we use background + border change + a checkmark concept via `aria-checked` styling). If selected chips look indistinguishable to the eye, add a checkmark icon: edit the chip ` + +
+
+
+
+ + One Young World Impact Report: Social Return of Investment: $682,495,840.00 (680 million dollars). Page 176 of the report. + +
+
+ + Forbes 30 Under 30 Asia Social Impact Organisation + +
+
+ + UNDP - Samsung Generation 17 + +
+
+
+
+ \ No newline at end of file diff --git a/partials/featured.hbs b/partials/featured.hbs index eb9c675d..44d8d4ba 100644 --- a/partials/featured.hbs +++ b/partials/featured.hbs @@ -1,26 +1,52 @@ -{{#get "posts" filter="featured:true" limit="all" as |featured|}} +{{#get "posts" filter="featured:true" include="tags,excerpt" limit="all" as |featured|}} {{#if featured}} -
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ diff --git a/partials/our-programmes.hbs b/partials/our-programmes.hbs new file mode 100644 index 00000000..dd353268 --- /dev/null +++ b/partials/our-programmes.hbs @@ -0,0 +1,55 @@ +
+ +
+
+
+ Events +
+

Events

+

Advisory organizes various interactive industry events. Check out our event highlights!

+ + + +
+
+
+ +
+
+ Mentorship +
+

Mentorship

+

The Advisory Mentorship Programme pairs students with working professionals in their fields of interest.

+ + + +
+
+
+ +
+
+ Interviews +
+

Interviews

+

Advisory conducts interviews with professionals from a wide range of fields. Read our articles here!

+ + + +
+
+
+
+
\ No newline at end of file diff --git a/partials/pagination.hbs b/partials/pagination.hbs index 13c4b36c..1a723cd0 100644 --- a/partials/pagination.hbs +++ b/partials/pagination.hbs @@ -1,13 +1,13 @@ - -
-
-
-
- -
- + +
+
+
+
+ +
+ {{#contentFor "body_class"}}{{#if next}} paged-next{{/if}}{{/contentFor}} \ No newline at end of file diff --git a/partials/post-card.hbs b/partials/post-card.hbs index 6fa0c111..c7adefec 100644 --- a/partials/post-card.hbs +++ b/partials/post-card.hbs @@ -1,11 +1,21 @@ -
-
+{{!-- NOTE: assets/js/related-posts.js → buildCardElement() mirrors this + structure for JS-rendered related-posts cards. Keep them in sync — + class names, child element order, and tag/title/excerpt/date + placement are all reproduced there. --}} +
+
{{#if feature_image}} - {{title}} + {{title}} {{else}} {{!-- Placeholder --}} - + {{/if}} -
+
{{#if primary_tag}} {{#primary_tag}} @@ -14,8 +24,8 @@ {{/primary_tag}} - {{else}} -

[No tag]

+ {{else}} + [No tag] {{/if}}

{{title}}

{{excerpt}}

@@ -25,5 +35,8 @@
+ + +
diff --git a/partials/post-filter-list.hbs b/partials/post-filter-list.hbs new file mode 100644 index 00000000..1ecd200d --- /dev/null +++ b/partials/post-filter-list.hbs @@ -0,0 +1,181 @@ +{{!-- Reusable filter UI + post grid + load-more. + Parameters: + collection — string label (used as DOM id namespace) + filter — Ghost filter string passed to {{#get "posts"}} + mode — "or" (default) or "and" --}} + +
+ {{!-- Toolbar: search input (left, flex-1) + sort/tags controls (right). --}} +
+ {{!-- min-width inline because Tailwind 3.0 JIT in this project + doesn't reliably emit arbitrary values like min-w-[200px]. --}} +
+ + + + Searching… + +
+
+
+ + +
+ + +
+
+
+ + +
+ +
+
+ + {{!-- Search error banner --}} + + + {{!-- Live result count for screen readers --}} +
+ +
+ + {{!-- Unknown-tags banner --}} +
+ The following tags don't exist on this page: + +
+ + {{!-- Empty state --}} +
+

No posts match your search and filters.

+

No posts match your search.

+

No posts match your selected tags.

+

No posts to show.

+ +
+ + {{!-- Server-rendered post grid. 20 stacked page blocks (limit=100 each) work + around Ghost's silent 100-post cap on limit="all". Hard ceiling: 2000 posts. + NO x-cloak: JS-disabled users still see all posts. --}} +
+ {{> "post-filter-page" page="1" filter=filter}} + {{> "post-filter-page" page="2" filter=filter}} + {{> "post-filter-page" page="3" filter=filter}} + {{> "post-filter-page" page="4" filter=filter}} + {{> "post-filter-page" page="5" filter=filter}} + {{> "post-filter-page" page="6" filter=filter}} + {{> "post-filter-page" page="7" filter=filter}} + {{> "post-filter-page" page="8" filter=filter}} + {{> "post-filter-page" page="9" filter=filter}} + {{> "post-filter-page" page="10" filter=filter}} + {{> "post-filter-page" page="11" filter=filter}} + {{> "post-filter-page" page="12" filter=filter}} + {{> "post-filter-page" page="13" filter=filter}} + {{> "post-filter-page" page="14" filter=filter}} + {{> "post-filter-page" page="15" filter=filter}} + {{> "post-filter-page" page="16" filter=filter}} + {{> "post-filter-page" page="17" filter=filter}} + {{> "post-filter-page" page="18" filter=filter}} + {{> "post-filter-page" page="19" filter=filter}} + {{> "post-filter-page" page="20" filter=filter}} +
+ + {{!-- Load more button --}} +
+ +
+
diff --git a/partials/post-filter-page.hbs b/partials/post-filter-page.hbs new file mode 100644 index 00000000..765cab0b --- /dev/null +++ b/partials/post-filter-page.hbs @@ -0,0 +1,21 @@ +{{!-- Renders one page (up to 100 posts) for post-filter-list.hbs. + Receives: + page — string "1".."20" (Ghost's {{#get}} accepts string page arg) + filter — Ghost filter expression (same one used on every page block) + Cards are wrapped in .filter-card-wrapper so the parent Alpine component + can show/hide them via x-show. The wrapper carries tabindex="-1" so + loadMore() in post-filter-list.js can programmatically focus the first + newly-revealed card for keyboard users. --}} + +{{!-- limit=100 is Ghost's per-page max for {{#get}}. --}} +{{#get "posts" limit="100" page=page filter=filter include="tags" order="published_at desc" as |posts|}} + + {{#foreach posts}} +
+ {{> "post-card"}} +
+ {{/foreach}} +{{/get}} diff --git a/partials/pswp.hbs b/partials/pswp.hbs deleted file mode 100644 index 5886c9ac..00000000 --- a/partials/pswp.hbs +++ /dev/null @@ -1,52 +0,0 @@ - - \ No newline at end of file diff --git a/partials/related.hbs b/partials/related.hbs index bd0e0f3e..5970e4f3 100644 --- a/partials/related.hbs +++ b/partials/related.hbs @@ -1,14 +1,29 @@ -{{#get "posts" limit="6" filter="tags:[{{post.tags}}]+id:-{{post.id}}" include="tags" as |related|}} - {{#if related}} - diff --git a/partials/search-pagination.hbs b/partials/search-pagination.hbs new file mode 100644 index 00000000..638e0b5d --- /dev/null +++ b/partials/search-pagination.hbs @@ -0,0 +1,23 @@ +
+
+
+ +
+
\ No newline at end of file diff --git a/partials/tags-listing.hbs b/partials/tags-listing.hbs new file mode 100644 index 00000000..a73ca480 --- /dev/null +++ b/partials/tags-listing.hbs @@ -0,0 +1,25 @@ +
+ + + + +
\ No newline at end of file diff --git a/post.hbs b/post.hbs index 5cb4d410..f37e5764 100644 --- a/post.hbs +++ b/post.hbs @@ -1,15 +1,40 @@ -{{!< default}} - -
-
- {{#post}} - {{> content width="wide"}} - {{/post}} - {{#is "post"}} - {{#if @custom.show_related_posts}} - {{> related}} - {{/if}} - {{> comment}} - {{/is}} -
-
+{{!< default}} + +{{#contentFor "title"}}{{meta_title}}{{/contentFor}} + +
+
+ + + +
+ {{#if @custom.show_related_posts}} + {{> related}} + {{/if}} + {{> comment}} + {{/is}} +
+ +
diff --git a/routes.yaml b/routes.yaml deleted file mode 100644 index 167ff9a8..00000000 --- a/routes.yaml +++ /dev/null @@ -1,11 +0,0 @@ -routes: - /: home - -collections: - /stories/: - permalink: /{slug}/ - template: index - -taxonomies: - tag: /tag/{slug}/ - author: /author/{slug}/ diff --git a/tag.hbs b/tag.hbs index abd21823..a0ad829f 100644 --- a/tag.hbs +++ b/tag.hbs @@ -1,5 +1,7 @@ {{!< default}} +{{#contentFor "title"}}{{meta_title}}{{/contentFor}} +
{{#tag}} @@ -9,31 +11,21 @@ {{#if description}}
{{description}}
{{/if}} - {{#get "tags" limit="all" filter="id:-{{id}}+visibility:public" as |tags|}} - {{#if tags}} -
- More: - {{#foreach tags}} - {{name}} - {{/foreach}} -
- {{/if}} - {{/get}}
{{#if feature_image}}
-
+
{{name}}
{{/if}} {{/tag}} -
+
{{#foreach posts}} {{> "post-card"}} {{/foreach}}
{{pagination}} -
+
\ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index b802f123..59bb42d5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,12 +5,33 @@ module.exports = { "**/*.hbs", "assets/built/**/*.js", "!node_modules/**/*.hbs", + "./node_modules/flowbite/**/*.js", + ], + safelist: [ + "bg-amber-300", + "text-sm", + "text-gray-800", + "h-6", + "capitalize", + "mr-1", ], theme: { - extend: {}, + colors: { + brand: { + light: "#ffad33", + DEFAULT: "#f49200", + }, + }, + extend: { + boxShadow: { + "featured-article": "0px 10px 50px -6px rgb(0 0 0 / 25%)", + }, + }, }, variants: { - extend: {}, + extend: { + padding: ["hover"], + }, }, - plugins: [require("@tailwindcss/line-clamp")], + plugins: [require("flowbite/plugin")], };