diff --git a/.env.development b/.env.development index 239142da6..06c8721f8 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,2 @@ ENV=development -CACHE_KEY=dev_2020_08 +CACHE_KEY=dev_2021_06 diff --git a/.env.production b/.env.production index 7309ce789..1ac73ab6d 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ ENV=production -CACHE_KEY=30swp20200826234402 +CACHE_KEY=30swp20210617172504 diff --git a/.env.test b/.env.test index b0692fdd8..c6ba1ed66 100644 --- a/.env.test +++ b/.env.test @@ -1,2 +1,2 @@ ENV=test -CACHE_KEY=test_2021_04 +CACHE_KEY=test_2021_06 diff --git a/.github/config.yml b/.github/config.yml deleted file mode 100644 index c62bc052d..000000000 --- a/.github/config.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Configuration for request-info - https://github.com/behaviorbot/request-info - -# *Required* Comment to reply with -requestInfoReplyComment: > - We would appreciate it if you could provide us with some more information about this issue/PR! - -# *OPTIONAL* default titles to check against for lack of descriptiveness -# MUST BE ALL LOWERCASE -requestInfoDefaultTitles: - - update readme.md - - updates - -# *OPTIONAL* Label to be added to Issues and Pull Requests with insufficient information given -requestInfoLabelToAdd: needs-more-info diff --git a/.github/lock.yml b/.github/lock.yml deleted file mode 100644 index 197497fe2..000000000 --- a/.github/lock.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Configuration for Lock Threads - https://github.com/dessant/lock-threads - -# Number of days of inactivity before a closed issue or pull request is locked -daysUntilLock: 60 - -# Skip issues and pull requests created before a given timestamp. Timestamp must -# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable -skipCreatedBefore: false - -# Issues and pull requests with these labels will be ignored. Set to `[]` to disable -exemptLabels: [] - -# Label to add before locking, such as `outdated`. Set to `false` to disable -lockLabel: false - -# Comment to post before locking. Set to `false` to disable -lockComment: false - -# Assign `resolved` as the reason for locking. Set to `false` to disable -setLockReason: true - -# Limit to only `issues` or `pulls` -# only: issues - -# Optionally, specify configuration settings just for `issues` or `pulls` -# issues: -# exemptLabels: -# - help-wanted -# lockLabel: outdated - -# pulls: -# daysUntilLock: 30 - -# Repository to extend settings from -# _extends: repo diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 03a77e140..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,18 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 21 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - not-stale - - pinned - - security -# Label to use when marking an issue as stale -staleLabel: false -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.gitmodules b/.gitmodules index dfcfd343a..df3846aa0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,11 +3,6 @@ url = https://github.com/30-seconds/30-seconds-of-python branch = master update = checkout -[submodule "content/sources/30php"] - path = content/sources/30php - url = https://github.com/30-seconds/30-seconds-of-php - branch = master - update = checkout [submodule "content/sources/30code"] path = content/sources/30code url = https://github.com/30-seconds/30-seconds-of-code @@ -23,26 +18,11 @@ url = https://github.com/30-seconds/30-seconds-of-react branch = master update = checkout -[submodule "content/sources/30csharp"] - path = content/sources/30csharp - url = https://github.com/30-seconds/30-seconds-of-csharp - branch = master - update = checkout [submodule "content/sources/30blog"] path = content/sources/30blog url = https://github.com/30-seconds/30-seconds-blog branch = master update = checkout -[submodule "content/sources/30dart"] - path = content/sources/30dart - url = https://github.com/30-seconds/30-seconds-of-dart - branch = master - update = checkout -[submodule "content/sources/30golang"] - path = content/sources/30golang - url = https://github.com/30-seconds/30-seconds-of-golang - branch = master - update = checkout [submodule "content/configs"] path = content/configs url = https://github.com/30-seconds/30-seconds-content diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b86a5149e..05e815838 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,9 @@ # Contribution guidelines -**30 seconds** is a community effort, so feel free to contribute in any way you can. Every contribution helps! +**30 seconds of code** is a community effort, so feel free to contribute in any way you can. Every contribution helps! Here's what you can do to help: - [Open issues](https://github.com/30-seconds/30-seconds-web/issues/new) for any bugs with the website, new features you want to request or changes you would like to see in the future. Try to use the appropriate template and provide as much information as possible. - Submit [pull requests](https://github.com/30-seconds/30-seconds-web/pulls) for changes to the website, bug fixes and new features. Remember to reference any open issues your pull request is related to and provide any relevant information. -- Submit issues or pull requests for similar topics in relation to content to the appropriate content repository under the [30-seconds organization](https://github.com/30-seconds). +- Submit issues or pull requests for similar topics in relation to content to the appropriate content repository under the [30 seconds of code organization](https://github.com/30-seconds). diff --git a/README.md b/README.md index 1e1b8b059..33b06855d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ [![Logo](/assets/logo.png)](https://www.30secondsofcode.org/) -# 30 seconds website platform +# 30 seconds of code website platform -Website infrastructure for [30-seconds](https://github.com/30-seconds) projects. +Website infrastructure for [30-seconds of code](https://github.com/30-seconds) projects. Please refer to individual projects for content issues. This repository contains the source code for the website platform and nothing else. diff --git a/assets/30s-icon.png b/assets/30s-icon.png index 095dfb35a..ec60cfeb2 100644 Binary files a/assets/30s-icon.png and b/assets/30s-icon.png differ diff --git a/assets/Inter-Italic.woff2 b/assets/Inter-Italic.woff2 deleted file mode 100644 index 734944b11..000000000 Binary files a/assets/Inter-Italic.woff2 and /dev/null differ diff --git a/assets/Inter-Medium.woff2 b/assets/Inter-Medium.woff2 deleted file mode 100644 index ffb4206c2..000000000 Binary files a/assets/Inter-Medium.woff2 and /dev/null differ diff --git a/assets/Inter-Regular.woff2 b/assets/Inter-Regular.woff2 deleted file mode 100644 index 66691b83a..000000000 Binary files a/assets/Inter-Regular.woff2 and /dev/null differ diff --git a/assets/Inter-SemiBold.woff2 b/assets/Inter-SemiBold.woff2 deleted file mode 100644 index 9fd7726eb..000000000 Binary files a/assets/Inter-SemiBold.woff2 and /dev/null differ diff --git a/assets/Inter.var.woff2 b/assets/Inter.var.woff2 new file mode 100644 index 000000000..b40083cbb Binary files /dev/null and b/assets/Inter.var.woff2 differ diff --git a/assets/logo.png b/assets/logo.png index f61c0ed81..a7661d15e 100644 Binary files a/assets/logo.png and b/assets/logo.png differ diff --git a/content/configs b/content/configs index f91d157c3..9c76bfdc7 160000 --- a/content/configs +++ b/content/configs @@ -1 +1 @@ -Subproject commit f91d157c379107f11787791c8293f89dd67ea319 +Subproject commit 9c76bfdc73d8509c4aa73003e158fd62c534d8b2 diff --git a/content/sources/30blog b/content/sources/30blog index 7545f6759..541ff1f40 160000 --- a/content/sources/30blog +++ b/content/sources/30blog @@ -1 +1 @@ -Subproject commit 7545f67593b1f297c0f135b8c4355499d12573e7 +Subproject commit 541ff1f4086955091fadb5e4e9763b43040c9cb8 diff --git a/content/sources/30code b/content/sources/30code index 85494928d..38a4ede2d 160000 --- a/content/sources/30code +++ b/content/sources/30code @@ -1 +1 @@ -Subproject commit 85494928d61ab099046df905dcf83dfb92c7cff5 +Subproject commit 38a4ede2dbb2325cd6a2c3f519ce46363db2e588 diff --git a/content/sources/30csharp b/content/sources/30csharp deleted file mode 160000 index 34003a7ce..000000000 --- a/content/sources/30csharp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 34003a7ce73573aa4cdb3da4cba4a568d0097cba diff --git a/content/sources/30css b/content/sources/30css index f839446d1..5ce7904a4 160000 --- a/content/sources/30css +++ b/content/sources/30css @@ -1 +1 @@ -Subproject commit f839446d1eb31b111ccadfbbb0afe3875b10acbe +Subproject commit 5ce7904a49aec1f4e966ccd983ccfb7efb259d33 diff --git a/content/sources/30dart b/content/sources/30dart deleted file mode 160000 index 71af0bb6a..000000000 --- a/content/sources/30dart +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 71af0bb6ad778da0fbba484fb6af1b3d113ba107 diff --git a/content/sources/30golang b/content/sources/30golang deleted file mode 160000 index 979e8c2ef..000000000 --- a/content/sources/30golang +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 979e8c2efa410e2d1cd84aef8fc13be748af9394 diff --git a/content/sources/30php b/content/sources/30php deleted file mode 160000 index f524f1fa0..000000000 --- a/content/sources/30php +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f524f1fa01c5a94c7df43ed6351def0548150d8a diff --git a/content/sources/30python b/content/sources/30python index 4c07c3ceb..0fc00171a 160000 --- a/content/sources/30python +++ b/content/sources/30python @@ -1 +1 @@ -Subproject commit 4c07c3ceb025cbafb78460c6524a6dba2fc87b06 +Subproject commit 0fc00171a2175e8240b570ded93f9dbc7f82e321 diff --git a/content/sources/30react b/content/sources/30react index a676be8c5..bfb1a3026 160000 --- a/content/sources/30react +++ b/content/sources/30react @@ -1 +1 @@ -Subproject commit a676be8c5ff9ddba49f3d41480e25c022859f24b +Subproject commit bfb1a302628959f3d675795dcfdd9ed0ceaf8e50 diff --git a/package-lock.json b/package-lock.json index a694813f2..49ad9091c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1393,12 +1393,6 @@ "to-fast-properties": "^2.0.0" } }, - "@chalarangelo/combine-class-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@chalarangelo/combine-class-names/-/combine-class-names-1.0.0.tgz", - "integrity": "sha512-jj73V9Uu4d9EUuhyx1/DPVplPE45fQNaRiyXG5NzufKumhmQT3cFhG6r8yxPGxgNRGYgp33yg1d05HfFWi7T8A==", - "dev": true - }, "@cnakazawa/watch": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", diff --git a/package.json b/package.json index 5de9393f3..5a8be9b68 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@babel/plugin-proposal-class-properties": "^7.13.0", "@babel/preset-env": "^7.13.15", "@babel/preset-react": "^7.13.13", - "@chalarangelo/combine-class-names": "^1.0.0", "@fixture-factory/fixture-factory": "^1.0.1", "@testing-library/react": "^10.4.8", "@types/jest": "^26.0.19", diff --git a/public/_redirects b/public/_redirects index 904550d3f..8254d493d 100644 --- a/public/_redirects +++ b/public/_redirects @@ -10,7 +10,7 @@ # # * Redirect replaced/renamed snippets. # ----------------------------------------------------------------------------- -/css/s/easing-variables /blog/s/css-easing-variables 301! +/css/s/easing-variables /articles/s/css-easing-variables 301! /python/s/count-occurences /python/s/count-occurrences 301! # ----------------------------------------------------------------------------- @@ -30,65 +30,36 @@ /php /php/p/1 301! # ----------------------------------------------------------------------------- -# UPDATED LISTING REDIRECTS: +# SORTED LISTING PAGE REDIRECTS: # -# * Redirect main listing with deprecated order to page 1 of the dedicated -# listing sorted by popularity. -# * Redirect blog listing with deprecated order to page 1 of the blog listing -# sorted by freshness. +# * Redirect alternative sorting listings to page 1 of the dedicated listing. # ----------------------------------------------------------------------------- -/list/e/1/ /list/p/1 301! -/list/e/2/ /list/p/1 301! -/list/e/3/ /list/p/1 301! -/list/e/4/ /list/p/1 301! -/list/e/5/ /list/p/1 301! -/list/e/6/ /list/p/1 301! -/list/e/7/ /list/p/1 301! -/list/e/8/ /list/p/1 301! -/list/e/9/ /list/p/1 301! -/list/e/10/ /list/p/1 301! -/list/e/11/ /list/p/1 301! -/list/e/12/ /list/p/1 301! -/list/e/13/ /list/p/1 301! -/list/e/14/ /list/p/1 301! -/list/e/15/ /list/p/1 301! -/list/e/16/ /list/p/1 301! -/list/e/17/ /list/p/1 301! -/list/e/18/ /list/p/1 301! -/list/e/19/ /list/p/1 301! -/list/e/20/ /list/p/1 301! -/list/e/21/ /list/p/1 301! -/list/e/22/ /list/p/1 301! -/list/e/23/ /list/p/1 301! -/list/e/24/ /list/p/1 301! -/list/a/1/ /list/p/1 301! -/list/a/2/ /list/p/1 301! -/list/a/3/ /list/p/1 301! -/list/a/4/ /list/p/1 301! -/list/a/5/ /list/p/1 301! -/list/a/6/ /list/p/1 301! -/list/a/7/ /list/p/1 301! -/list/a/8/ /list/p/1 301! -/list/a/9/ /list/p/1 301! -/list/a/10/ /list/p/1 301! -/list/a/11/ /list/p/1 301! -/list/a/12/ /list/p/1 301! -/list/a/13/ /list/p/1 301! -/list/a/14/ /list/p/1 301! -/list/a/15/ /list/p/1 301! -/list/a/16/ /list/p/1 301! -/list/a/17/ /list/p/1 301! -/list/a/18/ /list/p/1 301! -/list/a/19/ /list/p/1 301! -/list/a/20/ /list/p/1 301! -/list/a/21/ /list/p/1 301! -/list/a/22/ /list/p/1 301! -/list/a/23/ /list/p/1 301! -/list/a/24/ /list/p/1 301! -/blog/e/1/ /blog/n/1 301! -/blog/e/2/ /blog/n/1 301! -/blog/a/1/ /blog/n/1 301! -/blog/a/2/ /blog/n/1 301! +/blog/n/* /articles/p/1 301! +/blog/e/* /articles/p/1 301! +/blog/a/* /articles/p/1 301! +/list/e/* /list/p/1 301! +/list/a/* /list/p/1 301! +/:lang/a/* /:lang/p/1 301! +/:lang/e/* /:lang/p/1 301! +/:lang/t/:tag/a/* /:lang/t/:tag/p/1 301! +/:lang/t/:tag/e/* /:lang/t/:tag/p/1 301! + +# ----------------------------------------------------------------------------- +# ARTICLE URL REDIRECTS: +# +# * Redirect all blog urls to new articles urls. +# ----------------------------------------------------------------------------- +/blog/* /articles/:splat 301! + +# ----------------------------------------------------------------------------- +# REMOVED CONTENT REDIRECTS: +# +# * Redirect removed language content to the homepage with a 410 (gone forever). +# ----------------------------------------------------------------------------- +/dart/* / 410 +/go/* / 410 +/php/* / 410 +/c-sharp/* / 410 # ----------------------------------------------------------------------------- # UPDATED TAG REDIRECTS: @@ -110,12 +81,12 @@ python/t/object/e/1/ /python/t/dictionary/e/1 # ----------------------------------------------------------------------------- # SNIPPET REDIRECTS: # -# * Redirect an old CSS snippet to a new blog (CSS variables) +# * Redirect an old CSS snippet to a new article (CSS variables) # * Redirect an old CSS snippet to the CSS listing (CSS calc) # * Redirect an old JS snippet to its duplicate # * Redirect a couple of Python snippets to their new URLs # ----------------------------------------------------------------------------- -css/s/custom-variables/ /blog/s/css-variables 301! +css/s/custom-variables/ /articles/s/css-variables 301! css/s/calc/ /css/p/1 301! js/s/filter-falsy/ /js/s/compact 301! python/s/function-name/ /python/s/check-prop 301! diff --git a/src/blocks/adapters/snippetCollectionListing/index.js b/src/blocks/adapters/snippetCollectionListing/index.js index 5d067de85..48dd18efa 100644 --- a/src/blocks/adapters/snippetCollectionListing/index.js +++ b/src/blocks/adapters/snippetCollectionListing/index.js @@ -10,11 +10,9 @@ export class SnippetCollectionListing { /** * Creates a listing from the given SnippetCollection object. * @param {Snippet} snippet - A snippet to create a context from. - * @param {object} options - Options object, containing the following: - * - `order` - Order segment of the page slug. (default: `'p'`) * @throws Will throw an error if snippetCollection is not a SnippetCollection. */ - constructor(snippetCollection, { order = 'p' } = {}) { + constructor(snippetCollection) { if (!(snippetCollection instanceof SnippetCollection)) { throw new ArgsError( "Invalid arguments. 'snippetCollection' must be an instance of 'SnippetCollection'." @@ -22,9 +20,6 @@ export class SnippetCollectionListing { } this.snippetCollection = snippetCollection; - this._options = { - order, - }; } get listingName() { @@ -56,16 +51,15 @@ export class SnippetCollectionListing { get listingSublinks() { if (['blog', 'language', 'tag'].includes(this.snippetCollection.type)) { - const order = this._options.order; return [ { name: literals.tag('all'), - url: `${this.snippetCollection.rootUrl}/${order}/1`, + url: `${this.snippetCollection.rootUrl}/p/1`, selected: this.snippetCollection.type === 'language', }, ...this.snippetCollection.tags.map(tag => ({ name: literals.tag(tag), - url: `${this.snippetCollection.rootUrl}/t/${tag}/${order}/1`, + url: `${this.snippetCollection.rootUrl}/t/${tag}/p/1`, selected: this.snippetCollection.type === 'tag' ? tag === this.snippetCollection.tag @@ -92,11 +86,8 @@ export class SnippetCollectionListing { /** * Creates a plain object for the given snippet collection listing. - * @param {object} options - Options object, containing the following: - * - `order` - Order segment of the page slug. (default: `'p'`) */ - toObject = ({ order = this._options.order } = this._options) => { - this._options.order = order; + toObject = () => { return SnippetCollectionListing.serializableAttributes.reduce( (obj, attr) => { const val = this[attr]; diff --git a/src/blocks/adapters/snippetContext/index.js b/src/blocks/adapters/snippetContext/index.js index aeeec5000..3afba3dc2 100644 --- a/src/blocks/adapters/snippetContext/index.js +++ b/src/blocks/adapters/snippetContext/index.js @@ -78,7 +78,7 @@ export class SnippetContext { } get expertise() { - return Tag.format(this.snippet.expertise); + return this.snippet.expertise; } get language() { @@ -103,8 +103,17 @@ export class SnippetContext { return this.snippet.html; } + get actionType() { + if (this.snippet.config.isBlog) return undefined; + return this.snippet.config.isCSS + ? 'cssCodepen' + : this.snippet.config.isReact + ? 'codepen' + : 'copy'; + } + get code() { - return this.snippet.code; + return this.snippet.config.isCSS ? this.snippet.code : undefined; } get vscodeUrl() { @@ -124,6 +133,7 @@ export class SnippetContext { 'icon', 'tags', 'html', + 'actionType', 'code', 'authors', 'type', diff --git a/src/blocks/adapters/snippetContext/index.test.js b/src/blocks/adapters/snippetContext/index.test.js index 2920a7c6c..4da9c8b77 100644 --- a/src/blocks/adapters/snippetContext/index.test.js +++ b/src/blocks/adapters/snippetContext/index.test.js @@ -3,14 +3,21 @@ import { ArgsError } from 'blocks/utilities/error'; import SnippetFactory from 'test/fixtures/factories/blockSnippet'; describe('SnippetContext', () => { - let snippet, snippetContext, blogSnippet, blogSnippetContext; + let snippet, + snippetContext, + blogSnippet, + blogSnippetContext, + cssSnippet, + cssSnippetContext; beforeAll(() => { const snippets = SnippetFactory.create('SnippetPresets'); snippet = snippets.snippet; blogSnippet = snippets.blogSnippet; + cssSnippet = snippets.cssSnippet; snippetContext = new SnippetContext(snippet); blogSnippetContext = new SnippetContext(blogSnippet); + cssSnippetContext = new SnippetContext(cssSnippet); }); describe('constructor', () => { @@ -20,20 +27,26 @@ describe('SnippetContext', () => { }); describe('toObject', () => { - let result, blogResult; + let result, blogResult, cssResult; beforeAll(() => { result = snippetContext.toObject({ withVscodeUrl: true }); blogResult = blogSnippetContext.toObject({ withVscodeUrl: false }); + cssResult = cssSnippetContext.toObject({ withVscodeUrl: true }); }); it('returns the appropriate attributes', () => { expect(Object.keys(blogResult)).toEqual( SnippetContext.serializableAttributes.filter( - a => !['code', 'vscodeUrl'].includes(a) + a => !['code', 'vscodeUrl', 'actionType'].includes(a) ) ); expect(Object.keys(result)).toEqual( + SnippetContext.serializableAttributes.filter( + a => !['authors', 'type', 'cover', 'code'].includes(a) + ) + ); + expect(Object.keys(cssResult)).toEqual( SnippetContext.serializableAttributes.filter( a => !['authors', 'type', 'cover'].includes(a) ) @@ -101,8 +114,14 @@ describe('SnippetContext', () => { expect(result.html).toStrictEqual(snippet.html); }); + it('returns the correct actionType', () => { + expect(result.actionType).toBe('codepen'); + expect(cssResult.actionType).toBe('cssCodepen'); + expect(blogResult.actionType).toBe(undefined); + }); + it('returns the correct code', () => { - expect(result.code).toBe(snippet.code); + expect(cssResult.code).toBe(cssSnippet.code); }); it('returns the correct vscodeUrl', () => { diff --git a/src/blocks/adapters/snippetPreview/index.js b/src/blocks/adapters/snippetPreview/index.js index cb158fcaa..b600b41ff 100644 --- a/src/blocks/adapters/snippetPreview/index.js +++ b/src/blocks/adapters/snippetPreview/index.js @@ -38,7 +38,7 @@ export class SnippetPreview { } get expertise() { - return Tag.format(this.snippet.expertise); + return this.snippet.expertise; } get primaryTag() { diff --git a/src/blocks/entities/contentConfig/index.js b/src/blocks/entities/contentConfig/index.js index 72e4be738..4172abc1f 100644 --- a/src/blocks/entities/contentConfig/index.js +++ b/src/blocks/entities/contentConfig/index.js @@ -124,6 +124,10 @@ export class ContentConfig { return this.dirName === '30css'; } + get isReact() { + return this.dirName === '30react'; + } + get hasOptionalLanguage() { if (!this._hasOptionalLanguage) { this._hasOptionalLanguage = Boolean( diff --git a/src/blocks/entities/snippet/index.js b/src/blocks/entities/snippet/index.js index bee7bed54..dcf1a8ae7 100644 --- a/src/blocks/entities/snippet/index.js +++ b/src/blocks/entities/snippet/index.js @@ -147,6 +147,7 @@ export class Snippet { this._expertise = this.config.isBlog ? 'article' : Tag.determineExpertise(this.tags.all); + this._expertise = this._expertise.toLowerCase(); } return this._expertise; } diff --git a/src/blocks/entities/snippetCollection/index.js b/src/blocks/entities/snippetCollection/index.js index d3450a14a..5cebf8bc4 100644 --- a/src/blocks/entities/snippetCollection/index.js +++ b/src/blocks/entities/snippetCollection/index.js @@ -99,24 +99,6 @@ export class SnippetCollection { return `${this.type}${this.slugPrefix}`; } - get orders() { - if (!this._orders) { - this._orders = ['p']; - if ( - this.type === 'blog' || - (this.type === 'tag' && !this.config.language) - ) { - this._orders.push('n'); - } else if ( - this.type === 'language' || - (this.type === 'tag' && this.config.language) - ) { - this._orders.push('a', 'e'); - } - } - return this._orders; - } - get tagMetadata() { if (!this._tagMetadata) { this._tagMetadata = diff --git a/src/blocks/entities/snippetCollection/index.test.js b/src/blocks/entities/snippetCollection/index.test.js index 8b7f11692..a455d9f82 100644 --- a/src/blocks/entities/snippetCollection/index.test.js +++ b/src/blocks/entities/snippetCollection/index.test.js @@ -154,13 +154,6 @@ describe('SnippetCollection', () => { expect(collections.language.id).toBe('language/dart'); }); - it('should produce the correct orders', () => { - expect(collections.main.orders).toEqual(['p']); - expect(collections.blog.orders).toEqual(['p', 'n']); - expect(collections.language.orders).toEqual(['p', 'a', 'e']); - expect(collections.tag.orders).toEqual(['p', 'a', 'e']); - }); - it('should produce the correct tag metadata', () => { expect(collections.main.tagMetadata).toBeFalsy(); expect(collections.tag.tagMetadata).toBeFalsy(); diff --git a/src/blocks/parsers/markdown/index.js b/src/blocks/parsers/markdown/index.js index d1f9c04f8..d012c8d31 100644 --- a/src/blocks/parsers/markdown/index.js +++ b/src/blocks/parsers/markdown/index.js @@ -64,13 +64,6 @@ const blogTransformers = [ matcher: /\s*\n*\s*\s*\n*\s*\s*\n*\s*
<\/th>/g, replacer: '', }, - // Convert image credit to the appropriate element - { - blogType: 'any', - matcher: /

\s*\n*\s*Image credit:<\/strong>([\s\S]*?)<\/p>/g, - replacer: - '

Image credit: $1

', - }, ]; /** diff --git a/src/blocks/parsers/text/index.js b/src/blocks/parsers/text/index.js index 40d6688d4..59f3532c1 100644 --- a/src/blocks/parsers/text/index.js +++ b/src/blocks/parsers/text/index.js @@ -13,11 +13,30 @@ export class TextParser { /** * Reads the data from a text file, using frontmatter. * @param {string} filePath - Path of the given file - * @param {object} options - An options object, containing the following: - * - `withMetadata`: Should include git metadata for the returned object? * @returns {Promise} A promise that resolves to the object containing the file's data. */ - static fromPath = (filePath, { withMetadata = false } = {}) => { + static fromPath = filePath => { + const fileName = filePath.match(/.*\/([^/]*)$/)[1]; + return readFile(filePath, 'utf8') + .then(content => { + const { body, attributes } = frontmatter(content); + const { + firstSeen = '2021-06-13T05:00:00-04:00', + lastUpdated = firstSeen, + ...restAttributes + } = attributes; + return { + body, + ...restAttributes, + firstSeen: new Date(firstSeen), + lastUpdated: new Date(lastUpdated), + fileName, + }; + }) + .catch(err => err); + }; + + static fromPathOld = (filePath, { withMetadata = false } = {}) => { const [, dirPath, fileName] = filePath.match(/(.*)\/([^/]*)$/); const promises = [ new Promise((res, rej) => diff --git a/src/blocks/parsers/text/index.test.js b/src/blocks/parsers/text/index.test.js index 3fcbabd28..77c13c2aa 100644 --- a/src/blocks/parsers/text/index.test.js +++ b/src/blocks/parsers/text/index.test.js @@ -4,51 +4,30 @@ jest.mock('fs-extra', () => ({ readFile: jest.fn((path, format, callback) => callback( null, - '---\ntitle: Snippet\ntags: array,object\n---\n\nThis is some text.\n' + '---\ntitle: Snippet\ntags: array,object\nfirstSeen: 2017-12-22T21:54:30+02:00\nlastUpdated: 2017-12-22T21:54:30+02:00\n---\n\nThis is some text.\n' ) ), readdir: jest.fn((path, callback) => callback(null, ['any.md'])), })); -jest.mock('child_process', () => ({ - exec: jest.fn((command, callback) => callback(null, 'mocked return')), -})); - describe('TextParser', () => { describe('fromPath', () => { - it('returns a promise', () => { - expect( - TextParser.fromPath('content/sources/30code/snippets/any.md') instanceof - Promise - ).toBeTruthy(); - }); - - it('the resolved object contains the correct keys', () => { + it('the resolved object contains the correct keys and metadata', () => { return TextParser.fromPath('content/sources/30code/snippets/any.md').then( data => { expect(Object.keys(data).sort()).toEqual( - ['body', 'fileName', 'tags', 'title'].sort() + [ + 'body', + 'fileName', + 'tags', + 'title', + 'firstSeen', + 'lastUpdated', + ].sort() ); } ); }); - - it('the resolved object contains the correct keys and metadata', () => { - return TextParser.fromPath('content/sources/30code/snippets/any.md', { - withMetadata: true, - }).then(data => { - expect(Object.keys(data).sort()).toEqual( - [ - 'body', - 'fileName', - 'tags', - 'title', - 'firstSeen', - 'lastUpdated', - ].sort() - ); - }); - }); }); describe('fromDir', () => { diff --git a/src/blocks/serializers/asset/index.js b/src/blocks/serializers/asset/index.js index 29e43c484..4b5e0c6b8 100644 --- a/src/blocks/serializers/asset/index.js +++ b/src/blocks/serializers/asset/index.js @@ -61,7 +61,7 @@ export class AssetSerializer { boundLog('Processing assets from config...', 'info'); boundLog( - `Copying static assets from ${path.resolve(inPath)} to ${path.resolve( + `Processing static assets from ${path.resolve(inPath)} to ${path.resolve( outPath )}`, 'info' @@ -69,7 +69,13 @@ export class AssetSerializer { fs.ensureDirSync(outPath); await fs.copy(inPath, outPath); await fs.copy(inContentPath, outPath); - boundLog('Static assets have been copied', 'success'); + const staticAssets = glob + .sync(`${inContentPath}/*.@(${supportedExtensions.join('|')})`) + .map(file => path.resolve(file)); + await Promise.all( + staticAssets.map(asset => this.processImageAsset(asset, outPath)) + ); + boundLog('Processing static assets complete', 'success'); boundLog(`Processing image assets from configuration files`, 'info'); for (const cfg of configs) { diff --git a/src/blocks/serializers/hub/index.js b/src/blocks/serializers/hub/index.js index 8fbcbd5ad..61a33c424 100644 --- a/src/blocks/serializers/hub/index.js +++ b/src/blocks/serializers/hub/index.js @@ -76,7 +76,7 @@ export class HubSerializer { { shelfType: 'snippets', shelfName: literals.newBlogs, - shelfUrl: `${blogCollection.slugPrefix}/n/1`, + shelfUrl: `${blogCollection.slugPrefix}/p/1`, shelfData: newBlogs.map(s => new SnippetPreview(s).toObject()), }, { diff --git a/src/blocks/serializers/json/index.js b/src/blocks/serializers/json/index.js index 289798279..9a46415a9 100644 --- a/src/blocks/serializers/json/index.js +++ b/src/blocks/serializers/json/index.js @@ -6,6 +6,7 @@ const writeFile = util.promisify(fs.writeFile); * Serializes objects to JSON files. */ export class JSONSerializer { + static space = process.env.NODE_ENV === 'production' ? 0 : 2; /** * Writes the provided object to the specified file * @param {string} filePath - Path to write the file. @@ -13,7 +14,7 @@ export class JSONSerializer { * @returns {Promise} - A promise that resolves as soon as the file has been written */ static serializeToFile = (filePath, obj) => - writeFile(filePath, JSON.stringify(obj, null, 2)); + writeFile(filePath, JSON.stringify(obj, null, JSONSerializer.space)); /** * Writes the provided chunks to the specified directory. @@ -31,7 +32,10 @@ export class JSONSerializer { return Promise.all( dataChunkPairs.map(([key, value]) => - writeFile(`${path}/${key}.json`, JSON.stringify(value, null, 2)) + writeFile( + `${path}/${key}.json`, + JSON.stringify(value, null, JSONSerializer.space) + ) ) ); }; diff --git a/src/blocks/serializers/listing/index.js b/src/blocks/serializers/listing/index.js index 369f6c082..de5db4e25 100644 --- a/src/blocks/serializers/listing/index.js +++ b/src/blocks/serializers/listing/index.js @@ -1,6 +1,4 @@ import { SnippetCollection } from 'blocks/entities/snippetCollection'; -import EXPERTISE_LEVELS from 'settings/expertise'; -import literals from 'lang/en/listing'; import { SnippetCollectionListing } from 'blocks/adapters/snippetCollectionListing'; import { ArgsError } from 'blocks/utilities/error'; import { JSONSerializer } from 'blocks/serializers/json'; @@ -9,13 +7,6 @@ import { Logger } from 'blocks/utilities/logger'; import { chunk } from 'utils'; import { SnippetPreview } from 'blocks/adapters/snippetPreview'; -const ORDERS_MAP = { - p: literals.orders.popularity, - a: literals.orders.alphabetical, - e: literals.orders.expertise, - n: literals.orders.newest, -}; - const CARDS_PER_PAGE = 15; const BLOG_DEMOTION_RANKING_MULTIPLIER = 0.85; @@ -47,126 +38,100 @@ export class ListingSerializer { !['main', 'blog', 'collection'].includes(snippetCollection.type) && !(snippetCollection.type === 'tag' && !snippetCollection.config.language); - for (let order of snippetCollection.orders) { - const paginatedSnippets = this._paginateOrderedSnippets( - snippetCollection.isListed - ? snippetCollection.snippets.filter(s => s.isListed) - : snippetCollection.snippets, - order, - demoteBlogs - ); + const isOrderedByNew = + snippetCollection.type === 'blog' || + (snippetCollection.type === 'tag' && !snippetCollection.config.language); + + const paginatedSnippets = this._paginateSnippets( + snippetCollection.isListed + ? snippetCollection.snippets.filter(s => s.isListed) + : snippetCollection.snippets, + isOrderedByNew, + demoteBlogs + ); - const isPopularityOrdered = order === 'p'; - const isMainListing = - snippetCollection.type === 'main' && isPopularityOrdered; - const isTopLevelListing = - snippetCollection.isTopLevel && isPopularityOrdered; + const isMainListing = snippetCollection.type === 'main'; + const isTopLevelListing = snippetCollection.isTopLevel; - for (let [i, pageSnippets] of paginatedSnippets.entries()) { - const isFirstPage = isPopularityOrdered && i === 0; - const isMainListingFirstPage = isMainListing && isFirstPage; - const isTopLevelListingFirstPage = isTopLevelListing && isFirstPage; - const isMainTagListing = - snippetCollection.type === 'tag' && isFirstPage; + for (let [i, pageSnippets] of paginatedSnippets.entries()) { + const isFirstPage = i === 0; + const isMainListingFirstPage = isMainListing && isFirstPage; + const isTopLevelListingFirstPage = isTopLevelListing && isFirstPage; + const isMainTagListing = snippetCollection.type === 'tag' && isFirstPage; - const pageNum = i + 1; - const pageSlug = `${snippetCollection.slugPrefix}/${order}/${pageNum}`; - const priority = isMainListingFirstPage - ? 1.0 - : isTopLevelListingFirstPage || isMainListing - ? 0.75 - : isMainTagListing || isTopLevelListing - ? 0.5 - : 0.25; - const outDir = `${outDirPath}${pageSlug}`; + const pageNum = i + 1; + const pageSlug = `${snippetCollection.slugPrefix}/p/${pageNum}`; + const priority = isMainListingFirstPage + ? 1.0 + : isTopLevelListingFirstPage || isMainListing + ? 0.75 + : isMainTagListing || isTopLevelListing + ? 0.5 + : 0.25; + const outDir = `${outDirPath}${pageSlug}`; - const chunkPairs = [ - ['index', Chunk.createIndex(pageSlug, 'ListingPage', priority)], - ['snippetList', { snippetList: pageSnippets }], - [ - 'metadata', - { - isMainListing, - slug: pageSlug, - paginator: { - pageNumber: pageNum, - totalPages: paginatedSnippets.length, - baseUrl: snippetCollection.slugPrefix, - slugOrderingSegment: order, - }, - sorter: { - orders: snippetCollection.orders.map(order => ({ - url: `${snippetCollection.slugPrefix}/${order}/1`, - title: ORDERS_MAP[order], - })), - selectedOrder: ORDERS_MAP[order], - }, - ...snippetCollectionListing.toObject({ order }), + const chunkPairs = [ + ['index', Chunk.createIndex(pageSlug, 'ListingPage', priority)], + ['snippetList', { snippetList: pageSnippets }], + [ + 'metadata', + { + isMainListing, + slug: pageSlug, + paginator: { + pageNumber: pageNum, + totalPages: paginatedSnippets.length, + baseUrl: snippetCollection.slugPrefix, }, - ], - ]; - try { - await JSONSerializer.serializeToDir(outDir, ...chunkPairs); - } catch (err) { - boundLog( - `Encountered an error while serializing ${snippetCollection.name}`, - 'error' - ); - boundLog(`${err}`, 'error'); - throw err; - } + ...snippetCollectionListing.toObject(), + }, + ], + ]; + try { + await JSONSerializer.serializeToDir(outDir, ...chunkPairs); + } catch (err) { + boundLog( + `Encountered an error while serializing ${snippetCollection.name}`, + 'error' + ); + boundLog(`${err}`, 'error'); + throw err; } } }; - static _paginateOrderedSnippets = (snippets, order, demoteBlogs = false) => { - const snippetPreviews = snippets.map(s => new SnippetPreview(s).toObject()); - switch (order) { - case 'a': - return chunk( - snippetPreviews.sort((a, b) => a.title.localeCompare(b.title)), - CARDS_PER_PAGE - ); - case 'e': - return chunk( - snippetPreviews.sort((a, b) => - a.expertise === b.expertise - ? a.title.localeCompare(b.title) - : !a.expertise - ? 1 - : !b.expertise - ? -1 - : EXPERTISE_LEVELS.indexOf(a.expertise) - - EXPERTISE_LEVELS.indexOf(b.expertise) - ), - CARDS_PER_PAGE - ); - case 'n': - return chunk( + static _paginateSnippets = ( + snippets, + isOrderedByNew = false, + demoteBlogs = false + ) => { + if (isOrderedByNew) { + return chunk( + snippets + .sort((a, b) => +new Date(b.firstSeen) - +new Date(a.firstSeen)) + .map(s => new SnippetPreview(s).toObject()), + CARDS_PER_PAGE + ); + } + // It is assumed that snippets are always sorted by ranking in the SnippetCollection + return demoteBlogs + ? chunk( snippets - .sort((a, b) => +new Date(b.firstSeen) - +new Date(a.firstSeen)) + .sort((a, b) => { + const nA = a.type.startsWith('blog') + ? a.ranking * BLOG_DEMOTION_RANKING_MULTIPLIER + : a.ranking; + const nB = b.type.startsWith('blog') + ? b.ranking * BLOG_DEMOTION_RANKING_MULTIPLIER + : b.ranking; + return nB - nA; + }) .map(s => new SnippetPreview(s).toObject()), CARDS_PER_PAGE + ) + : chunk( + snippets.map(s => new SnippetPreview(s).toObject()), + CARDS_PER_PAGE ); - // It is assumed that snippets are always sorted by ranking in the SnippetCollection - case 'p': - default: - return demoteBlogs - ? chunk( - snippets - .sort((a, b) => { - const nA = a.type.startsWith('blog') - ? a.ranking * BLOG_DEMOTION_RANKING_MULTIPLIER - : a.ranking; - const nB = b.type.startsWith('blog') - ? b.ranking * BLOG_DEMOTION_RANKING_MULTIPLIER - : b.ranking; - return nB - nA; - }) - .map(s => new SnippetPreview(s).toObject()), - CARDS_PER_PAGE - ) - : chunk(snippetPreviews, CARDS_PER_PAGE); - } }; } diff --git a/src/blocks/serializers/snippet/index.js b/src/blocks/serializers/snippet/index.js index 9dd103e1c..a0ea6739a 100644 --- a/src/blocks/serializers/snippet/index.js +++ b/src/blocks/serializers/snippet/index.js @@ -12,7 +12,7 @@ import { Logger } from 'blocks/utilities/logger'; * Serializes a Snippet object into appropriate JSON files. */ export class SnippetSerializer { - static isDevelopment = process.env.NODE_ENV === `production`; + static isDevelopment = process.env.NODE_ENV === 'development'; /** * Serializes a Snippet object into JSON files. * @param {Snippet} snippet - A snippet object. @@ -55,7 +55,7 @@ export class SnippetSerializer { 'snippet', { snippet: new SnippetContext(snippet).toObject({ - withVscodeUrl: Boolean(this.isDevelopment), + withVscodeUrl: Boolean(SnippetSerializer.isDevelopment), }), }, ], diff --git a/src/blocks/utilities/recommender/index.js b/src/blocks/utilities/recommender/index.js index a9971b78e..99c07a942 100644 --- a/src/blocks/utilities/recommender/index.js +++ b/src/blocks/utilities/recommender/index.js @@ -33,7 +33,7 @@ export class Recommender { ).toLowerCase(); const primaryTag = (isBlog ? Tag.stripLanguage(snippet.tags.all)[0] - : snippet.tags.primary + : snippet.tags.primary || '' ).toLowerCase(); const searchTokens = snippet.searchTokens.split(' '); diff --git a/src/blocks/utilities/tag/index.test.js b/src/blocks/utilities/tag/index.test.js index d75504d06..5104b37fe 100644 --- a/src/blocks/utilities/tag/index.test.js +++ b/src/blocks/utilities/tag/index.test.js @@ -18,7 +18,7 @@ describe('Tag', () => { expect(Tag.determineExpertise(['array', 'advanced'])).toBe('advanced'); }); it('returns default expertise if none is found', () => { - expect(Tag.determineExpertise(['array'])).toBe('Intermediate'); + expect(Tag.determineExpertise(['array'])).toBe('intermediate'); }); }); diff --git a/src/components/atoms/button/_index.scss b/src/components/atoms/button/_index.scss index 36cd047ae..39074e16b 100644 --- a/src/components/atoms/button/_index.scss +++ b/src/components/atoms/button/_index.scss @@ -1 +1,40 @@ -@import './regularButton'; +.btn { + display: inline-block; + padding: 0.625rem 0.875rem; + margin: 0 0.5rem; + font-weight: 500; + -webkit-font-smoothing: antialiased; + line-height: 1.5rem; + cursor: pointer; + border: none; + border-radius: var(--br-round); + transition: 0.3s ease all; + + &:hover, &:focus { + animation: none; + outline: none; + } + + // Utility for btn elements that look like actions + &.action-btn { + background: transparent; + color: var(--clr-txt-150); + + &:hover, &:focus { + background: var(--clr-el-01dp); + color: var(--clr-primary-100); + } + } + + &.outline-btn { + background: transparent; + border: 2px solid var(--clr-primary-100); + color: var(--clr-primary-050); + + &:hover, &:focus { + background: var(--clr-primary-200); + border: 2px solid var(--clr-primary-200); + color: var(--clr-txt-200); + } + } +} diff --git a/src/components/atoms/button/codepenButton/index.jsx b/src/components/atoms/button/codepenButton/index.jsx deleted file mode 100644 index 4e2ac2a00..000000000 --- a/src/components/atoms/button/codepenButton/index.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import PropTypes from 'typedefs/proptypes'; -import { useGtagEvent } from 'components/hooks'; -import literals from 'lang/en/client/common'; - -/* eslint-disable camelcase */ - -const propTypes = { - /** JS code to be passed to the CodePen definition */ - jsCode: PropTypes.string, - /** HTML code to be passed to the CodePen definition */ - htmlCode: PropTypes.string, - /** CSS code to be passed to the CodePen definition */ - cssCode: PropTypes.string, - /** JS preprocessor ("none" || "coffeescript" || "babel" || "livescript" || "typescript") */ - jsPreProcessor: PropTypes.string, - /** External JS files to be included */ - jsExternal: PropTypes.arrayOf(PropTypes.string), -}; - -/** - * Button that links to a generated Codepen from the page data. - * Uses the CodePen API: https://blog.codepen.io/documentation/api/prefill/ - * @param {string} jsCode - JavaScript code to be sent to the CodePen API - * @param {string} htmlCode - HTML code to be sent to the CodePen API - * @param {string} cssCode - CSS code to be sent to the CodePen API - * @param {string} jsPreProcessor - JS preprocessor, sent to the CodePen API. - * One of the following: "none", "coffeescript", "babel", "livescript", "typescript" - * @param {string} jsExternal - External JS files to be included, sent to the CodePen API - */ -const CodepenButton = ({ - jsCode, - htmlCode, - cssCode, - jsPreProcessor = 'none', - jsExternal = [], -}) => { - const gtagCallback = useGtagEvent('click'); - return ( -
- - - - ); -}; - -CodepenButton.propTypes = propTypes; - -export default CodepenButton; diff --git a/src/components/atoms/button/codepenButton/index.test.jsx b/src/components/atoms/button/codepenButton/index.test.jsx deleted file mode 100644 index fa73f7c4b..000000000 --- a/src/components/atoms/button/codepenButton/index.test.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { renderWithContext } from 'test/utils'; -import { cleanup } from '@testing-library/react'; -import literals from 'lang/en/client/common'; -import CodepenButton from './index'; - -const codepenHtmlCode = - '

Hello, this is white on red.

'; - -const codepenCssCode = `.my-special-snippet { - background: red; - color: white; -}`; - -jest.useFakeTimers(); - -describe('', () => { - let wrapper; - let button, input; - - beforeEach(() => { - wrapper = renderWithContext( - - ).container; - button = wrapper.querySelector('button'); - input = wrapper.querySelector('input'); - }); - - afterEach(cleanup); - - it('should render correctly', () => { - expect(wrapper.querySelectorAll('form')).toHaveLength(1); - expect(wrapper.querySelectorAll('form > input')).toHaveLength(1); - expect(wrapper.querySelectorAll('button.btn')).toHaveLength(1); - }); - - it('should have an appropriate title attribute', () => { - expect(wrapper.querySelectorAll('button.btn[title]')).toHaveLength(1); - expect(button.title).toBe(literals.codepen); - }); - - it('should pass data to the input field', () => { - expect(input.value).not.toBe(undefined); - }); -}); diff --git a/src/components/atoms/button/copyButton/index.jsx b/src/components/atoms/button/copyButton/index.jsx deleted file mode 100644 index 5bca6e83d..000000000 --- a/src/components/atoms/button/copyButton/index.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useState, useEffect } from 'react'; -import PropTypes from 'typedefs/proptypes'; -import { useGtagEvent } from 'components/hooks'; -import copyToClipboard from 'copy-to-clipboard'; -import { combineClassNames } from 'utils'; -import literals from 'lang/en/client/common'; - -const propTypes = { - text: PropTypes.string.isRequired, -}; - -/** - * Button that copies the given text to clipboard. - * Dependent on `copy-to-clipboard` external module. - * @param {string} text - Text to be copied when the button is clicked. - */ -const CopyButton = ({ text }) => { - const gtagCallback = useGtagEvent('click'); - const [active, setActive] = useState(false); - const [copying, setCopying] = useState(false); - const [buttonText, setButtonText] = useState(literals.copyToClipboard); - - // If `copying` is `true`, then play the activation animation. - useEffect(() => { - if (!copying) return; - copyToClipboard(text); - setTimeout(() => setActive(true), 100); - setTimeout(() => setActive(false), 750); - }, [copying]); - - // If `active` is `false`, set `copying` to false (finished activation animation). - useEffect(() => { - if (active) return; - setCopying(false); - setButtonText(literals.copyToClipboard); - }, [active]); - - return ( - - ); -}; - -CopyButton.propTypes = propTypes; - -export default CopyButton; diff --git a/src/components/atoms/button/copyButton/index.test.jsx b/src/components/atoms/button/copyButton/index.test.jsx deleted file mode 100644 index 65b2da735..000000000 --- a/src/components/atoms/button/copyButton/index.test.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { renderWithContext } from 'test/utils'; -import { cleanup, fireEvent, waitFor } from '@testing-library/react'; -import literals from 'lang/en/client/common'; -import copyToClipboard from 'copy-to-clipboard'; -import CopyButton from './index'; - -const copyToClipboardMock = jest.fn(); -jest.mock('copy-to-clipboard'); -jest.useFakeTimers(); - -describe('', () => { - let wrapper; - let button; - const copyText = 'Lorem ipsum'; - - beforeAll(() => { - copyToClipboard.mockImplementation(copyToClipboardMock); - }); - - beforeEach(() => { - wrapper = renderWithContext().container; - button = wrapper.querySelector('button'); - }); - - afterEach(cleanup); - - it('should render correctly', () => { - expect(wrapper.querySelectorAll('button.btn.icon-clipboard')).toHaveLength( - 1 - ); - }); - - it('should have an appropriate title attribute', () => { - expect(wrapper.querySelectorAll('button.btn[title]')).toHaveLength(1); - expect(button.title).toBe(literals.copyToClipboard); - }); - - describe('when clicked', () => { - it('should copy to clipboard and play the microinteraction animation', async () => { - fireEvent.click(button); - jest.advanceTimersByTime(100); - expect(copyToClipboardMock.mock.calls.length).toBeGreaterThan(0); - expect(setTimeout).toHaveBeenCalled(); - await waitFor(() => - expect(wrapper.querySelectorAll('button.btn.active')).toHaveLength(1) - ); - fireEvent.click(button); - jest.advanceTimersByTime(750); - await waitFor(() => - expect( - wrapper.querySelectorAll('button.btn:not(.active)') - ).toHaveLength(1) - ); - }); - }); -}); diff --git a/src/components/atoms/button/index.jsx b/src/components/atoms/button/index.jsx index 9e44b3c6a..aa3ed2067 100644 --- a/src/components/atoms/button/index.jsx +++ b/src/components/atoms/button/index.jsx @@ -1,6 +1,29 @@ -import Button from './regularButton'; -import CopyButton from './copyButton'; -import CodepenButton from './codepenButton'; -import ShareButton from './shareButton'; +import PropTypes from 'typedefs/proptypes'; -export { Button, CopyButton, CodepenButton, ShareButton }; +const propTypes = { + onClick: PropTypes.func, + className: PropTypes.string, + children: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node), + ]), + rest: PropTypes.any, +}; + +/** + * Generic button component. + */ +const Button = ({ + onClick, + className = '', + children, + ...rest // Needs props to have accessible name if only icon etc. +}) => ( + +); + +Button.propTypes = propTypes; + +export default Button; diff --git a/src/components/atoms/button/regularButton/index.test.jsx b/src/components/atoms/button/index.test.jsx similarity index 100% rename from src/components/atoms/button/regularButton/index.test.jsx rename to src/components/atoms/button/index.test.jsx diff --git a/src/components/atoms/button/regularButton/_index.scss b/src/components/atoms/button/regularButton/_index.scss deleted file mode 100644 index c5feca626..000000000 --- a/src/components/atoms/button/regularButton/_index.scss +++ /dev/null @@ -1,97 +0,0 @@ -.btn, a.btn { - display: inline-block; - padding: 0.625rem 0.875rem; - margin-top: 0.75rem; - background: var(--clr-btn-bg); - color: var(--clr-txt-200); - cursor: pointer; - border: none; - border-radius: var(--br-xl); - line-height: 1.5rem; - transition: 0.3s ease all; - box-shadow: var(--shd-el-02dp); - - &:hover, &:focus { - box-shadow: var(--shd-el-04dp); - animation: none; - outline: none; - } - - svg { - vertical-align: sub; - } - - &.active { - animation-name: active; - animation-duration: 0.5s; - animation-timing-function: cubic-bezier(.4,0,.2,1); - transition: background 0.25s ease; - - &:before { - opacity: 0; - animation-name: active-before; - animation-duration: 0.65s; - animation-timing-function: ease-in-out; - } - } - - // Utility to remove box-shadow from btn elements - &.no-shd { - box-shadow: none; - - &:hover, &:focus { - box-shadow: none; - } - } - - // Utility for btn elements that display a link color on hover - &.link-btn { - - &:hover, &:focus { - color: var(--clr-link); - } - } - - // Utility for btn elements that look like actions - &.action-btn { - background: transparent; - color: var(--clr-action); - text-transform: uppercase; - font-weight: 500; - -webkit-font-smoothing: antialiased; - opacity: .87; - - &:hover, &:focus { - opacity: 1; - } - } -} - -@keyframes active { - 0%, 100% { - transform: scale(1); - } - 20% { - transform: scale(1.15) rotate(3deg); - } - 40% { - transform: scale(.94); - } - 60% { - transform: scale(.98) rotate(-3deg); - } - 80% { - transform: scale(1.08); - } - 99% { - } -} - -@keyframes active-before { - 0%, 100% { - opacity: 0; - } - 30%, 75% { - opacity: 1.0; - } -} diff --git a/src/components/atoms/button/regularButton/index.jsx b/src/components/atoms/button/regularButton/index.jsx deleted file mode 100644 index 8ad24b870..000000000 --- a/src/components/atoms/button/regularButton/index.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'typedefs/proptypes'; -import { combineClassNames } from 'utils'; - -const propTypes = { - onClick: PropTypes.func, - className: PropTypes.string, - children: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.arrayOf(PropTypes.node), - ]), - rest: PropTypes.any, -}; - -/** - * Generic button component. - */ -const Button = ({ - onClick, - className = '', - children, - ...rest // Needs props to have accessible name if only icon etc. -}) => ( - -); - -Button.propTypes = propTypes; - -export default Button; diff --git a/src/components/atoms/button/shareButton/index.jsx b/src/components/atoms/button/shareButton/index.jsx deleted file mode 100644 index 5c437c7ad..000000000 --- a/src/components/atoms/button/shareButton/index.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useState, useEffect } from 'react'; -import PropTypes from 'typedefs/proptypes'; -import { useGtagEvent } from 'components/hooks'; -import literals from 'lang/en/client/common'; - -const propTypes = { - pageTitle: PropTypes.string, - pageDescription: PropTypes.string, -}; - -/** - * Button that shares the given page to clipboard. - */ -const ShareButton = ({ pageTitle, pageDescription }) => { - const gtagCallback = useGtagEvent('click'); - const [canShare, setCanShare] = useState(false); - useEffect(() => { - if (navigator && navigator.share) setCanShare(true); - }, []); - - if (!canShare) return null; - - return ( - - ); -}; - -ShareButton.propTypes = propTypes; - -export default ShareButton; diff --git a/src/components/atoms/card/_index.scss b/src/components/atoms/card/_index.scss index 2dcfb20e9..329ea3cc1 100644 --- a/src/components/atoms/card/_index.scss +++ b/src/components/atoms/card/_index.scss @@ -18,11 +18,8 @@ text-overflow: ellipsis; a { - &, &:link, &:visited { - font-weight: 500; - line-height: 1; - color: var(--clr-txt-200); - } + font-weight: 500; + line-height: 1; } } @@ -46,21 +43,9 @@ left: 0.5rem; font-size: 36px; } - - // Custom styles for dart icon - &.icon-dart:before { - content: ''; - background-image: generate-icon-background("dartlang", '', '', 0, 24); - width: 100%; - height: 100%; - top: 0; - left: 0; - background-repeat: no-repeat; - background-position: center; - } } - // Reusable utility for titles in the body of cards (used in blogs) + // Reusable utility for titles in the body of cards (used in articles) .card-body-title { line-height: 2rem; font-weight: 500; @@ -72,7 +57,6 @@ } .card-meta { - display: grid; grid-template-rows: auto auto; grid-template-columns: 64px auto; @@ -105,7 +89,7 @@ margin-top: 1rem; } - &:last-child:not(:first-child):not(.card-image-credit) { + &:last-child:not(:first-child) { margin-bottom: 1.375rem; } } @@ -141,23 +125,13 @@ min-height: calc(260px / var(--cover-aspect-ratio)); max-height: calc(680px / var(--cover-aspect-ratio)); } - - p:last-child.card-image-credit { - font-size: var(--font-xs); - padding: 0 1.5rem; - margin-top: -2.375rem; - background-color: var(--clr-img-credit-bg); - color: var(--clr-txt-150); - order: -1; - } } // List item card .list-card { overflow: hidden; - display: grid; grid-template-rows: auto auto; - grid-template-columns: 64px calc(100% - 64px); + grid-template-columns: 64px auto; content-visibility: auto; contain-intrinsic-size: 240px; diff --git a/src/components/atoms/card/index.jsx b/src/components/atoms/card/index.jsx index 14251dcca..0ffd9ee11 100644 --- a/src/components/atoms/card/index.jsx +++ b/src/components/atoms/card/index.jsx @@ -1,5 +1,4 @@ import PropTypes from 'typedefs/proptypes'; -import { combineClassNames } from 'utils'; const propTypes = { className: PropTypes.string, @@ -10,11 +9,8 @@ const propTypes = { * Generic card component. Renders a simple `
` element with a base class * and passes everything else to the element. */ -const Card = ({ className, ...rest }) => ( -
+const Card = ({ className = '', ...rest }) => ( +
); Card.propTypes = propTypes; diff --git a/src/components/atoms/codeBlock/index.jsx b/src/components/atoms/codeBlock/index.jsx index cf33e01a2..8d60aa976 100644 --- a/src/components/atoms/codeBlock/index.jsx +++ b/src/components/atoms/codeBlock/index.jsx @@ -1,5 +1,4 @@ import PropTypes from 'typedefs/proptypes'; -import { combineClassNames } from 'utils'; const propTypes = { language: PropTypes.language, @@ -11,10 +10,10 @@ const propTypes = { * Renders a code block with the appropriate language tag. * @param {string} htmlContent -Raw HTML string to be rendered inside the block. */ -const CodeBlock = ({ language, className, htmlContent }) => ( +const CodeBlock = ({ language, className = '', htmlContent }) => (
 );
diff --git a/src/components/atoms/collectionChip/_index.scss b/src/components/atoms/collectionChip/_index.scss
index 8adc36c68..57fb83848 100644
--- a/src/components/atoms/collectionChip/_index.scss
+++ b/src/components/atoms/collectionChip/_index.scss
@@ -8,10 +8,6 @@
     min-height: 64px;
     text-align: center;
 
-    &, &:link, &:visited {
-      color: var(--clr-txt-200);
-    }
-
     &::before {
       position: absolute;
       left: 0;
diff --git a/src/components/atoms/collectionChip/index.jsx b/src/components/atoms/collectionChip/index.jsx
index ec1b2b97d..86369c578 100644
--- a/src/components/atoms/collectionChip/index.jsx
+++ b/src/components/atoms/collectionChip/index.jsx
@@ -15,12 +15,12 @@ const CollectionChip = ({ chip }) => {
   const hasDescription = chip.description && chip.description.length;
 
   return hasDescription ? (
-    
  • +
  • - {chip.title} + {chip.title}

    @@ -32,10 +32,10 @@ const CollectionChip = ({ chip }) => {
  • ) : ( -
  • +
  • {chip.title} diff --git a/src/components/atoms/expertise/_index.scss b/src/components/atoms/expertise/_index.scss index e76ad564d..376674f8f 100644 --- a/src/components/atoms/expertise/_index.scss +++ b/src/components/atoms/expertise/_index.scss @@ -21,6 +21,6 @@ } &.article { - --expertise-color: var(--clr-exp-blog); + --expertise-color: var(--clr-exp-article); } } diff --git a/src/components/atoms/expertise/index.jsx b/src/components/atoms/expertise/index.jsx index 673bb9575..917907b97 100644 --- a/src/components/atoms/expertise/index.jsx +++ b/src/components/atoms/expertise/index.jsx @@ -7,10 +7,10 @@ const propTypes = { /** * Renders an expertise tag. * @param {string} level - One of the appropriate expertise levels: - * "Beginner", "Intermediate" (default), "Advanced", "Blog" + * "beginner", "intermediate" (default), "advanced", "article" */ -const Expertise = ({ level = 'Intermediate' }) => ( - +const Expertise = ({ level = 'intermediate' }) => ( + ); Expertise.propTypes = propTypes; diff --git a/src/components/atoms/expertise/index.test.jsx b/src/components/atoms/expertise/index.test.jsx index 8944d35d7..df468201a 100644 --- a/src/components/atoms/expertise/index.test.jsx +++ b/src/components/atoms/expertise/index.test.jsx @@ -2,7 +2,7 @@ import { render, cleanup } from '@testing-library/react'; import Expertise from './index'; describe('', () => { - const level = 'Beginner'; + const level = 'beginner'; let wrapper; beforeEach(() => { @@ -16,15 +16,7 @@ describe('', () => { }); it('should get the appropriate class from expertise level', () => { - expect( - wrapper.querySelectorAll(`.expertise.${level.toLowerCase()}`) - ).toHaveLength(1); - }); - - it('should get the appropriate title from expertise level', () => { - expect( - wrapper.querySelectorAll(`.expertise[title="${level}"`) - ).toHaveLength(1); + expect(wrapper.querySelectorAll(`.expertise.${level}`)).toHaveLength(1); }); describe('without a level value', () => { diff --git a/src/components/atoms/pageBackdrop/_index.scss b/src/components/atoms/pageBackdrop/_index.scss index f55b73109..080c2a9a3 100644 --- a/src/components/atoms/pageBackdrop/_index.scss +++ b/src/components/atoms/pageBackdrop/_index.scss @@ -20,7 +20,6 @@ .page-backdrop-text, .page-backdrop-subtext { - font-weight: 400; max-width: 200px; } diff --git a/src/components/atoms/pageBackdrop/index.jsx b/src/components/atoms/pageBackdrop/index.jsx index 3d73ce415..d7cfd844b 100644 --- a/src/components/atoms/pageBackdrop/index.jsx +++ b/src/components/atoms/pageBackdrop/index.jsx @@ -1,5 +1,4 @@ import PropTypes from 'typedefs/proptypes'; -import { combineClassNames } from 'utils'; const propTypes = { backdropImage: PropTypes.string, @@ -31,22 +30,27 @@ const propTypes = { const PageBackdrop = ({ backdropImage, mainText, - mainTextClassName, + mainTextClassName = '', subText, - subTextClassName, + subTextClassName = '', children, }) => (
    - -

    + + + + +

    {mainText}

    {subText ? ( -

    +

    {subText}

    ) : null} diff --git a/src/components/atoms/pageTitle/index.jsx b/src/components/atoms/pageTitle/index.jsx index 6e35f6fd5..b5c8aa323 100644 --- a/src/components/atoms/pageTitle/index.jsx +++ b/src/components/atoms/pageTitle/index.jsx @@ -1,5 +1,4 @@ import PropTypes from 'typedefs/proptypes'; -import { combineClassNames } from 'utils'; const propTypes = { className: PropTypes.string, @@ -13,10 +12,8 @@ const propTypes = { * Page title component. Renders a simple `

    ` element with a base class * and passes children to the element. */ -const PageTitle = ({ className, children }) => ( -

    +const PageTitle = ({ className = '', children }) => ( +

    {children}

    ); diff --git a/src/components/molecules/_index.scss b/src/components/molecules/_index.scss index 60375649c..ef420c86e 100644 --- a/src/components/molecules/_index.scss +++ b/src/components/molecules/_index.scss @@ -5,4 +5,3 @@ @import './listingAnchors'; @import './paginator'; @import './search'; -@import './sorter'; diff --git a/src/components/molecules/actions/_index.scss b/src/components/molecules/actions/_index.scss index 47caf1bfc..195221f2b 100644 --- a/src/components/molecules/actions/_index.scss +++ b/src/components/molecules/actions/_index.scss @@ -1,23 +1,59 @@ .card-actions { margin: 1rem 0 -1rem; - justify-content: space-around; - - .btn-form, .btn { - flex: 1 0 auto; - text-align: center; - } + justify-content: space-evenly; .btn { position: relative; margin: 0; - padding: 0.25rem; + padding: 0.75rem; &:before { font-size: 24px; vertical-align: bottom; width: 100%; - transition: opacity 0.3s ease; - margin-right: 1rem; } + + &.active { + animation-name: active; + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(.4,0,.2,1); + transition: background 0.25s ease; + + &:before { + opacity: 0; + animation-name: active-before; + animation-duration: 0.65s; + animation-timing-function: ease-in-out; + } + } + } +} + +@keyframes active { + 0%, 100% { + transform: scale(1); + } + 20% { + transform: scale(1.15) rotate(3deg); + } + 40% { + transform: scale(.94); + } + 60% { + transform: scale(.98) rotate(-3deg); + } + 80% { + transform: scale(1.08); + } + 99% { + } +} + +@keyframes active-before { + 0%, 100% { + opacity: 0; + } + 30%, 75% { + opacity: 1.0; } } diff --git a/src/components/molecules/actions/index.jsx b/src/components/molecules/actions/index.jsx index bf7922ed8..1153dfd89 100644 --- a/src/components/molecules/actions/index.jsx +++ b/src/components/molecules/actions/index.jsx @@ -1,12 +1,11 @@ +/* eslint-disable camelcase */ import PropTypes from 'typedefs/proptypes'; import { useGtagEvent } from 'components/hooks'; -import { - CopyButton, - CodepenButton, - ShareButton, -} from 'components/atoms/button'; +import copyToClipboard from 'copy-to-clipboard'; +import Button from 'components/atoms/button'; import JSX_SNIPPET_PRESETS from 'settings/jsxSnippetPresets'; import literals from 'lang/en/client/common'; +import { useEffect, useState } from 'react'; const propTypes = { snippet: PropTypes.snippet, @@ -18,49 +17,154 @@ const propTypes = { */ const Actions = ({ snippet }) => { const gtagCallback = useGtagEvent('click'); - const showCopy = - snippet.code && snippet.code.src && !snippet.language.otherLanguages; - const showCodepen = - snippet.code && snippet.code.src && snippet.language.otherLanguages; - const showCssCodepen = - snippet.code && snippet.code.css && snippet.language.otherLanguages; + + // Code state + const [{ src, css, html, js, style }, setCode] = useState({ + src: '', + css: '', + html: '', + js: '', + style: '', + }); + + useEffect(() => { + if (!document) return; + switch (snippet.actionType) { + case 'codepen': { + const codeBlock = document.querySelector( + '.card-code[data-code-language="React"]' + ); + let code = codeBlock ? codeBlock.innerText : ''; + const codeExample = document.querySelector('.card-example'); + code += codeExample ? codeExample.innerText : ''; + const styleBlock = document.querySelector( + '.card-code[data-code-language="CSS"]' + ); + setCode({ src: code, style: styleBlock ? styleBlock.innerText : '' }); + break; + } + case 'cssCodepen': { + setCode({ + html: snippet.code.html, + css: snippet.code.css, + js: snippet.code.js, + }); + break; + } + case 'copy': { + const codeBlock = document.querySelector('.card-code'); + setCode({ src: codeBlock ? codeBlock.innerText : '' }); + break; + } + default: + break; + } + }, [snippet]); + + // Share state + const [canShare, setCanShare] = useState(false); + + useEffect(() => { + if (navigator && navigator.share) setCanShare(true); + }, []); + + // Copy button state + const [active, setActive] = useState(false); + const [copying, setCopying] = useState(false); + + // If `copying` is `true`, then play the activation animation. + useEffect(() => { + if (!copying) return; + copyToClipboard(src); + setTimeout(() => setActive(true), 100); + setTimeout(() => setActive(false), 750); + }, [copying]); + + // If `active` is `false`, set `copying` to false (finished activation animation). + useEffect(() => { + if (active) return; + setCopying(false); + }, [active]); + + const isJsxCodepen = snippet.actionType === 'codepen'; + return (
    - - {showCopy && } - {showCodepen && ( - { + gtagCallback({ event_category: 'action-share', value: 1 }); + try { + navigator.share({ + title: snippet.title, + text: snippet.description, + url: document.querySelector('link[rel=canonical]').href, + }); + } catch (err) { + // display error message or feedback microinteraction + } + }} /> )} - {showCssCodepen && ( - { + gtagCallback({ event_category: 'action-copy', value: 1 }); + setCopying(true); + }} /> )} + {Boolean( + snippet.actionType === 'codepen' || snippet.actionType === 'cssCodepen' + ) && ( +
    + +
    ); }; diff --git a/src/components/molecules/actions/index.test.jsx b/src/components/molecules/actions/index.test.jsx index 12ca26bef..f836980b3 100644 --- a/src/components/molecules/actions/index.test.jsx +++ b/src/components/molecules/actions/index.test.jsx @@ -8,6 +8,7 @@ global.gtag = Object.create(() => null); const fullSnippet = SnippetFactory.create('FullSnippet'); const fullReactSnippet = SnippetFactory.create('FullReactSnippet'); +const fullCssSnippet = SnippetFactory.create('FullCssSnippet'); Object.defineProperty(window, 'gtag', { value: jest.fn(), @@ -31,7 +32,7 @@ describe('', () => { }); describe('with regular snippet', () => { - it('should render a CopyButton component', () => { + it('should render a copy button', () => { expect(wrapper.querySelectorAll('.icon-clipboard')).toHaveLength(1); }); }); @@ -42,7 +43,18 @@ describe('', () => { wrapper = utils.container; }); - it('should render a CodepenButton component', () => { + it('should render a codepen button', () => { + expect(wrapper.querySelectorAll('.icon-codepen')).toHaveLength(1); + }); + }); + + describe('with css snippet', () => { + beforeEach(() => { + const utils = renderWithContext(); + wrapper = utils.container; + }); + + it('should render a codepen button', () => { expect(wrapper.querySelectorAll('.icon-codepen')).toHaveLength(1); }); }); diff --git a/src/components/molecules/breadcrumbs/_index.scss b/src/components/molecules/breadcrumbs/_index.scss index 08f765fd5..76c00563a 100644 --- a/src/components/molecules/breadcrumbs/_index.scss +++ b/src/components/molecules/breadcrumbs/_index.scss @@ -9,23 +9,14 @@ .breadcrumb-item { position: relative; - margin: 0 1rem 0 0; - - // Breadcrumb link - a { - - &, &:link, &:visited { - color: var(--clr-txt-150); - } - } &:not(:last-of-type) { - &:before { + &:after { color: var(--clr-txt-050); - position: absolute; - right: -0.75rem; content: "/"; + width: 0; + padding: 0 0.25rem; } } @@ -37,10 +28,7 @@ text-overflow: ellipsis; a { - &, &:link, &:visited { - color: var(--clr-txt-050); - pointer-events: none; - } + pointer-events: none; } } } diff --git a/src/components/molecules/breadcrumbs/index.jsx b/src/components/molecules/breadcrumbs/index.jsx index 04234824e..b5dd648db 100644 --- a/src/components/molecules/breadcrumbs/index.jsx +++ b/src/components/molecules/breadcrumbs/index.jsx @@ -16,15 +16,26 @@ const breadcrumbPropTypes = { const Breadcrumbs = ({ breadcrumbs }) => ( ); diff --git a/src/components/molecules/cookieConsentPopup/_index.scss b/src/components/molecules/cookieConsentPopup/_index.scss index f22d227b4..de9091f35 100644 --- a/src/components/molecules/cookieConsentPopup/_index.scss +++ b/src/components/molecules/cookieConsentPopup/_index.scss @@ -20,9 +20,7 @@ } .btn { - margin: 0; - font-size: var(--font-xs); - padding: 0.625rem 1.375rem; flex: 0 1 27.5%; + margin-top: 0.5rem; } } diff --git a/src/components/molecules/cookieConsentPopup/index.jsx b/src/components/molecules/cookieConsentPopup/index.jsx index cf342cc90..ae76b2964 100644 --- a/src/components/molecules/cookieConsentPopup/index.jsx +++ b/src/components/molecules/cookieConsentPopup/index.jsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react'; -import PropTypes from 'typedefs/proptypes'; import Link from 'next/link'; -import { Button } from 'components/atoms/button'; +import Button from 'components/atoms/button'; import { useShellDispatch } from 'state/shell'; import literals from 'lang/en/client/cookieConsent'; @@ -36,7 +35,7 @@ const CookieConsentPopup = () => {

    ) : ( - - {buttonNumber} + + {buttonNumber} ) )} {pageNumber < totalPages && ( - +
  • {snippet.title} + {snippet.title}

    diff --git a/src/components/molecules/search/_index.scss b/src/components/molecules/search/_index.scss index be900039f..26263272f 100644 --- a/src/components/molecules/search/_index.scss +++ b/src/components/molecules/search/_index.scss @@ -17,13 +17,15 @@ .search-wrapper { position: relative; - width: calc(100% - 76px - 64px - 44px); background: var(--clr-search-bg); color: var(--clr-txt-100); transition: 0.3s ease all; + flex: 0 0 100%; + margin-bottom: 1rem; @media screen and (min-width: $layout-medium-breakpoint) { - width: 320px; + flex: 1 0 300px; + margin-bottom: 0; } &:not(:focus-within) .search-autocomplete-list { @@ -35,10 +37,15 @@ line-height: 44px; margin-left: 0.5rem; color: inherit; + transition: 0.3s ease all; } &:focus-within { color: var(--clr-txt-200); + + &::before { + transform: scale(1.1); + } } } @@ -51,7 +58,6 @@ box-sizing: border-box; padding: 0.25rem 0.5rem; font-size: var(--font-md); - font-weight: 400; height: 44px; &, &::placeholder { @@ -109,7 +115,7 @@ a.search-btn[aria-hidden="true"] { animation: none; .result-title, .result-tag { - color: var(--clr-link); + color: var(--clr-primary-050); } } } diff --git a/src/components/molecules/search/index.jsx b/src/components/molecules/search/index.jsx index 1598dbc0b..c627a8ac7 100644 --- a/src/components/molecules/search/index.jsx +++ b/src/components/molecules/search/index.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import PropTypes from 'typedefs/proptypes'; import { useRouter } from 'next/router'; -import { getURLParameters, throttle, combineClassNames } from 'utils'; +import { getURLParameters, throttle } from 'utils'; import { useSearch } from 'state/search'; import literals from 'lang/en/client/search'; @@ -24,9 +24,7 @@ const Search = ({ isMainSearch = false }) => { dispatch, ] = useSearch(); const [value, setValue] = useState(''); - const [searchIndexInitialized, setSearchIndexInitialized] = useState( - false - ); + const [searchIndexInitialized, setSearchIndexInitialized] = useState(false); const [selectedResult, setSelectedResult] = useState(-1); const hasResults = value.trim().length > 1 && searchResults.length !== 0; @@ -175,18 +173,18 @@ const Search = ({ isMainSearch = false }) => { {item.title} {!item.search ? ( - + {item.expertise ? item.language ? item.language - : item.expertise + : `${item.expertise[0].toUpperCase()}${item.expertise.slice( + 1 + )}` : literals.snippetCollectionShort} ) : null} diff --git a/src/components/molecules/sorter/_index.scss b/src/components/molecules/sorter/_index.scss deleted file mode 100644 index c6ddfb933..000000000 --- a/src/components/molecules/sorter/_index.scss +++ /dev/null @@ -1,48 +0,0 @@ -.sorter { - position: relative; - width: 172px; - height: 48px; - box-sizing: border-box; - margin-bottom: 0.25rem; - - .sorter-inner { - position: absolute; - top: 0; - left: 0; - width: 172px; - z-index: 100; - box-sizing: border-box; - transition: 0.3s ease box-shadow; - border-radius: var(--br-md); - padding-bottom: 10px; - } - - .order-btn { - position: relative; - padding-right: 48px; - height: 36px; - width: 172px; - margin-top: 0; - box-sizing: border-box; - background: transparent; - - &:before { - position: absolute; - top: 12px; - right: 12px; - font-size: 20px; - } - } - - &:not(.open) { - - .order-btn:not(.selected) { - display: none; - } - - .sorter-inner { - background: transparent; - box-shadow: none; - } - } -} diff --git a/src/components/molecules/sorter/index.jsx b/src/components/molecules/sorter/index.jsx deleted file mode 100644 index b8597c55a..000000000 --- a/src/components/molecules/sorter/index.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState, useRef } from 'react'; -import PropTypes from 'typedefs/proptypes'; -import Link from 'next/link'; -import { combineClassNames } from 'utils'; -import { useClickOutside } from 'components/hooks'; - -const propTypes = { - sorter: PropTypes.sorter, -}; - -/** - * Renders a sorter component. - */ -const Sorter = ({ sorter: { orders, selectedOrder } }) => { - if (!orders || !orders.length || orders.length === 1) return null; - - const [toggled, setToggled] = useState(false); - const sorterRef = useRef(); - - const handleSorterClick = e => { - if (!toggled || e.target.className.includes('selected')) { - e.preventDefault(); - if (!toggled) setToggled(true); - else setToggled(false); - } else if (toggled) { - setToggled(false); - } - }; - - useClickOutside(sorterRef, () => { - setToggled(false); - }); - - return ( - - ); -}; - -Sorter.propTypes = propTypes; - -export default Sorter; diff --git a/src/components/molecules/sorter/index.test.jsx b/src/components/molecules/sorter/index.test.jsx deleted file mode 100644 index 8a891c024..000000000 --- a/src/components/molecules/sorter/index.test.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, cleanup, fireEvent } from '@testing-library/react'; -import Sorter from './index'; -import SorterFactory from 'test/fixtures/factories/sorter'; - -const sorter = SorterFactory.create('Sorter'); - -describe('', () => { - let wrapper; - let clickableEl; - - beforeEach(() => { - wrapper = render().container; - }); - - afterEach(cleanup); - - describe('should render', () => { - it('the outer wrapper', () => { - expect(wrapper.querySelectorAll('.sorter')).toHaveLength(1); - }); - - it('the inner wrapper', () => { - expect(wrapper.querySelectorAll('.sorter-inner')).toHaveLength(1); - }); - - it('the correct amount of buttons', () => { - expect(wrapper.querySelectorAll('a.btn.order-btn')).toHaveLength(2); - }); - }); - - it('should not be open by default', () => { - expect(wrapper.querySelectorAll('.sorter.open')).toHaveLength(0); - }); - - describe('when clicked', () => { - beforeEach(() => { - clickableEl = wrapper.querySelector('.order-btn.selected'); - fireEvent.click(clickableEl); - }); - - it('should open the sorter', () => { - expect(wrapper.querySelectorAll('.sorter.open')).toHaveLength(1); - }); - - describe('a second time', () => { - beforeEach(() => { - fireEvent.click(clickableEl); - }); - - it('should close the sorter', () => { - expect(wrapper.querySelectorAll('.sorter.open')).toHaveLength(0); - }); - }); - }); - - describe('with a single sorting order', () => { - beforeEach(() => { - wrapper = render( - - ).container; - }); - - it('should not render', () => { - expect(wrapper.querySelectorAll('.sorter')).toHaveLength(0); - }); - }); -}); diff --git a/src/components/organisms/meta/index.jsx b/src/components/organisms/meta/index.jsx index 4564d9e25..6ccdb14cc 100644 --- a/src/components/organisms/meta/index.jsx +++ b/src/components/organisms/meta/index.jsx @@ -115,21 +115,6 @@ const Meta = ({ }`, }); } - - // Hotjar - scripts.push({ - key: 'hotjar', - innerHTML: ` - (function(h,o,t,j,a,r){ - h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)}; - h._hjSettings={hjid:1988882,hjsv:6}; - a=o.getElementsByTagName('head')[0]; - r=o.createElement('script');r.async=1; - r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv; - a.appendChild(r); - })(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv='); - `, - }); } return ( diff --git a/src/components/organisms/searchResults/_index.scss b/src/components/organisms/searchResults/_index.scss index b0ac27e2d..fc9aef6f7 100644 --- a/src/components/organisms/searchResults/_index.scss +++ b/src/components/organisms/searchResults/_index.scss @@ -2,3 +2,7 @@ .page-backdrop-text.search-page-text { margin-top: 12vmin; } + +.search-filters { + margin: 0.5rem 0.875rem -0.5rem; +} diff --git a/src/components/organisms/searchResults/index.jsx b/src/components/organisms/searchResults/index.jsx index cb838c2dd..93f2b73e1 100644 --- a/src/components/organisms/searchResults/index.jsx +++ b/src/components/organisms/searchResults/index.jsx @@ -1,11 +1,12 @@ import PropTypes from 'typedefs/proptypes'; -import { useSearchState } from 'state/search'; +import { useSearch } from 'state/search'; import PageBackdrop from 'components/atoms/pageBackdrop'; import PageTitle from 'components/atoms/pageTitle'; import PreviewCard from 'components/molecules/previewCard'; import CollectionChip from 'components/atoms/collectionChip'; import RecommendationList from 'components/organisms/recommendationList'; import literals from 'lang/en/client/search'; +import Button from 'components/atoms/button'; const propTypes = { recommendedSnippets: PropTypes.arrayOf(PropTypes.snippet), @@ -17,14 +18,44 @@ const propTypes = { * Dependent on multiple components. */ const SearchResults = ({ recommendedSnippets = [] }) => { - const { searchQuery, searchResults } = useSearchState(); + const [ + { + searchQuery, + searchResults, + filteredResults, + availableFilters, + typeFilter, + }, + dispatch, + ] = useSearch(); const hasResults = searchQuery.trim().length > 1 && searchResults.length !== 0; return hasResults ? ( <> {literals.results} + {Boolean(availableFilters.length > 2) && ( +
      + {availableFilters.map(type => ( +
    • + +
    • + ))} +
    + )}