From 8afce10f243118040454657ee76c8d8319169534 Mon Sep 17 00:00:00 2001 From: Lawrence Davis Date: Thu, 9 Jan 2025 17:02:44 -0600 Subject: [PATCH] Add nx server+client apps and shared lib --- .env | 15 + .env.sample | 15 + .envrc | 1 + .gitignore | 5 + .prettierignore | 3 +- .prettierrc | 10 +- README.md | 90 +- apps/spin-cycle-client/eslint.config.cjs | 34 + apps/spin-cycle-client/project.json | 95 + apps/spin-cycle-client/public/favicon.ico | Bin 0 -> 15086 bytes .../src/app/app.component.html | 9 + .../src/app/app.component.scss | 0 .../src/app/app.component.spec.ts | 30 + .../src/app/app.component.ts | 22 + apps/spin-cycle-client/src/app/app.config.ts | 14 + apps/spin-cycle-client/src/app/app.routes.ts | 25 + .../src/app/auth/auth.component.html | 0 .../src/app/auth/auth.component.scss | 0 .../src/app/auth/auth.component.spec.ts | 22 + .../src/app/auth/auth.component.ts | 34 + .../src/app/auth/auth.guard.ts | 8 + .../src/app/auth/auth.interceptor.ts | 24 + .../src/app/auth/auth.service.ts | 35 + .../src/app/auth/unauth.guard.ts | 8 + .../src/app/history/history.component.html | 1 + .../src/app/history/history.component.scss | 0 .../src/app/history/history.component.spec.ts | 22 + .../src/app/history/history.component.ts | 10 + .../src/app/settings.service.spec.ts | 16 + .../src/app/settings/settings.component.html | 17 + .../src/app/settings/settings.component.scss | 0 .../app/settings/settings.component.spec.ts | 22 + .../src/app/settings/settings.component.ts | 46 + .../src/app/settings/settings.service.ts | 25 + .../src/app/storage.service.spec.ts | 16 + .../src/app/storage.service.ts | 44 + apps/spin-cycle-client/src/app/window-ref.ts | 5 + .../src/environments/environment.prod.ts | 0 .../src/environments/environment.ts | 4 + apps/spin-cycle-client/src/index.html | 13 + apps/spin-cycle-client/src/main.ts | 9 + apps/spin-cycle-client/src/styles.scss | 2 + .../spin-cycle-client/src/styles/_custom.scss | 7 + .../src/styles/boilerplate.css | 1408 + apps/spin-cycle-client/src/test-setup.ts | 5 + apps/spin-cycle-client/tsconfig.app.json | 25 + apps/spin-cycle-client/tsconfig.editor.json | 21 + apps/spin-cycle-client/tsconfig.json | 32 + apps/spin-cycle-client/tsconfig.spec.json | 23 + apps/spin-cycle-client/vite.config.mts | 27 + apps/spin-cycle/jwtconfig.ts | 7 + apps/spin-cycle/ormconfig.ts | 15 + apps/spin-cycle/src/app.controller.spec.ts | 23 + .../src/{app => }/app.controller.ts | 5 +- apps/spin-cycle/src/app.module.ts | 28 + apps/spin-cycle/src/{app => }/app.service.ts | 4 +- .../spin-cycle/src/app/app.controller.spec.ts | 21 - apps/spin-cycle/src/app/app.module.ts | 10 - apps/spin-cycle/src/app/app.service.spec.ts | 20 - apps/spin-cycle/src/auth/auth.controller.ts | 42 + apps/spin-cycle/src/auth/auth.guard.ts | 35 + apps/spin-cycle/src/auth/auth.module.ts | 12 + apps/spin-cycle/src/auth/oauth.service.ts | 149 + apps/spin-cycle/src/main.ts | 45 +- .../src/settings/settings.controller.ts | 39 + .../src/settings/settings.module.ts | 10 + apps/spin-cycle/src/user.module.ts | 12 + apps/spin-cycle/src/user.service.ts | 32 + apps/spin-cycle/tsconfig.spec.json | 7 +- compose.yml | 22 + eslint.config.cjs | 9 +- libs/shared/README.md | 7 + libs/shared/eslint.config.cjs | 19 + libs/shared/package.json | 11 + libs/shared/project.json | 20 + libs/shared/src/index.ts | 1 + libs/shared/src/lib/dto/token-out.ts | 11 + libs/shared/src/lib/dto/user-out.ts | 19 + libs/shared/src/lib/entities/spin.entity.ts | 27 + libs/shared/src/lib/entities/user.entity.ts | 49 + libs/shared/src/lib/shared.ts | 5 + libs/shared/tsconfig.json | 13 + libs/shared/tsconfig.lib.json | 9 + nx.json | 26 +- package-lock.json | 28377 +++++++++++----- package.json | 48 +- tsconfig.base.json | 4 +- vitest.workspace.ts | 1 + 88 files changed, 22018 insertions(+), 9475 deletions(-) create mode 100644 .env create mode 100644 .env.sample create mode 100644 .envrc create mode 100644 apps/spin-cycle-client/eslint.config.cjs create mode 100644 apps/spin-cycle-client/project.json create mode 100644 apps/spin-cycle-client/public/favicon.ico create mode 100644 apps/spin-cycle-client/src/app/app.component.html create mode 100644 apps/spin-cycle-client/src/app/app.component.scss create mode 100644 apps/spin-cycle-client/src/app/app.component.spec.ts create mode 100644 apps/spin-cycle-client/src/app/app.component.ts create mode 100644 apps/spin-cycle-client/src/app/app.config.ts create mode 100644 apps/spin-cycle-client/src/app/app.routes.ts create mode 100644 apps/spin-cycle-client/src/app/auth/auth.component.html create mode 100644 apps/spin-cycle-client/src/app/auth/auth.component.scss create mode 100644 apps/spin-cycle-client/src/app/auth/auth.component.spec.ts create mode 100644 apps/spin-cycle-client/src/app/auth/auth.component.ts create mode 100644 apps/spin-cycle-client/src/app/auth/auth.guard.ts create mode 100644 apps/spin-cycle-client/src/app/auth/auth.interceptor.ts create mode 100644 apps/spin-cycle-client/src/app/auth/auth.service.ts create mode 100644 apps/spin-cycle-client/src/app/auth/unauth.guard.ts create mode 100644 apps/spin-cycle-client/src/app/history/history.component.html create mode 100644 apps/spin-cycle-client/src/app/history/history.component.scss create mode 100644 apps/spin-cycle-client/src/app/history/history.component.spec.ts create mode 100644 apps/spin-cycle-client/src/app/history/history.component.ts create mode 100644 apps/spin-cycle-client/src/app/settings.service.spec.ts create mode 100644 apps/spin-cycle-client/src/app/settings/settings.component.html create mode 100644 apps/spin-cycle-client/src/app/settings/settings.component.scss create mode 100644 apps/spin-cycle-client/src/app/settings/settings.component.spec.ts create mode 100644 apps/spin-cycle-client/src/app/settings/settings.component.ts create mode 100644 apps/spin-cycle-client/src/app/settings/settings.service.ts create mode 100644 apps/spin-cycle-client/src/app/storage.service.spec.ts create mode 100644 apps/spin-cycle-client/src/app/storage.service.ts create mode 100644 apps/spin-cycle-client/src/app/window-ref.ts create mode 100644 apps/spin-cycle-client/src/environments/environment.prod.ts create mode 100644 apps/spin-cycle-client/src/environments/environment.ts create mode 100644 apps/spin-cycle-client/src/index.html create mode 100644 apps/spin-cycle-client/src/main.ts create mode 100644 apps/spin-cycle-client/src/styles.scss create mode 100644 apps/spin-cycle-client/src/styles/_custom.scss create mode 100644 apps/spin-cycle-client/src/styles/boilerplate.css create mode 100644 apps/spin-cycle-client/src/test-setup.ts create mode 100644 apps/spin-cycle-client/tsconfig.app.json create mode 100644 apps/spin-cycle-client/tsconfig.editor.json create mode 100644 apps/spin-cycle-client/tsconfig.json create mode 100644 apps/spin-cycle-client/tsconfig.spec.json create mode 100644 apps/spin-cycle-client/vite.config.mts create mode 100644 apps/spin-cycle/jwtconfig.ts create mode 100644 apps/spin-cycle/ormconfig.ts create mode 100644 apps/spin-cycle/src/app.controller.spec.ts rename apps/spin-cycle/src/{app => }/app.controller.ts (77%) create mode 100644 apps/spin-cycle/src/app.module.ts rename apps/spin-cycle/src/{app => }/app.service.ts (56%) delete mode 100644 apps/spin-cycle/src/app/app.controller.spec.ts delete mode 100644 apps/spin-cycle/src/app/app.module.ts delete mode 100644 apps/spin-cycle/src/app/app.service.spec.ts create mode 100644 apps/spin-cycle/src/auth/auth.controller.ts create mode 100644 apps/spin-cycle/src/auth/auth.guard.ts create mode 100644 apps/spin-cycle/src/auth/auth.module.ts create mode 100644 apps/spin-cycle/src/auth/oauth.service.ts create mode 100644 apps/spin-cycle/src/settings/settings.controller.ts create mode 100644 apps/spin-cycle/src/settings/settings.module.ts create mode 100644 apps/spin-cycle/src/user.module.ts create mode 100644 apps/spin-cycle/src/user.service.ts create mode 100644 compose.yml create mode 100644 libs/shared/README.md create mode 100644 libs/shared/eslint.config.cjs create mode 100644 libs/shared/package.json create mode 100644 libs/shared/project.json create mode 100644 libs/shared/src/index.ts create mode 100644 libs/shared/src/lib/dto/token-out.ts create mode 100644 libs/shared/src/lib/dto/user-out.ts create mode 100644 libs/shared/src/lib/entities/spin.entity.ts create mode 100644 libs/shared/src/lib/entities/user.entity.ts create mode 100644 libs/shared/src/lib/shared.ts create mode 100644 libs/shared/tsconfig.json create mode 100644 libs/shared/tsconfig.lib.json create mode 100644 vitest.workspace.ts diff --git a/.env b/.env new file mode 100644 index 0000000..0982de0 --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +export OAUTH_CALLBACK_URL="http://localhost:3000/auth/callback" +export POST_AUTH_REDIRECT="http://localhost:4200/auth" + +export DB_HOST="127.0.0.1" +export DB_PORT=54321 +export DB_USERNAME="spincycle" +export DB_PASSWORD="spincycle" +export DB_DATABASE="spincycle" +export REDIS_URL="redis://localhost:63791" +export DISCOGS_KEY="WnLGBmVhAiSqeOklehdG" +export DISCOGS_SECRET="GsxNVzKifRhjpqnMIBRenYRfmTMMYDko" +export DISCOGS_USER_AGENT="SpinCycle/0.1" + +export JWT_SECRET="a1f373cfe890e284a9027b56763b0d7898c1fdf84cbf41f1788d8824409472a6" +export SESSION_SECRET="791e1bd6315a06038d0c3fa3b82c5f9555162619d82373293234d4484d42da78" diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..979a02a --- /dev/null +++ b/.env.sample @@ -0,0 +1,15 @@ +export OAUTH_CALLBACK_URL="where the discogs oauth flow should redirect to" +export POST_AUTH_REDIRECT="the client URL that the API should redirect to after oauth" + +export DB_HOST="database host" +export DB_PORT=5432 +export DB_USERNAME="spincycle" +export DB_PASSWORD="spincycle" +export DB_DATABASE="spincycle" +export REDIS_URL="redis URL" +export DISCOGS_KEY="discogs consumer key" +export DISCOGS_SECRET="discogs consumer secret" +export DISCOGS_USER_AGENT="SpinCycle/0.1" + +export JWT_SECRET="secure secret" +export SESSION_SECRET="another secure secret" diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..fe7c01a --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv diff --git a/.gitignore b/.gitignore index ccbf371..36f6bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ Thumbs.db .nx/cache .nx/workspace-data + +.angular + +vite.config.*.timestamp* +vitest.config.*.timestamp* \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index e26f0b3..113709c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ /dist /coverage /.nx/cache -/.nx/workspace-data \ No newline at end of file +/.nx/workspace-data +.angular diff --git a/.prettierrc b/.prettierrc index 544138b..d1a33ee 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,11 @@ { - "singleQuote": true + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "singleQuote": true, + "printWidth": 120, + "semi": true, + "importOrderParserPlugins": ["typescript", "decorators-legacy"], + "importOrder": ["^@$", "", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "trailingComma": "all" } diff --git a/README.md b/README.md index e571f4e..3cdcd90 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,20 @@ -# SpinCycleMono +# Spin Cycle - +![](https://media3.giphy.com/media/xT9IgpTy4UVnddmso0/200.gif?cid=6c09b9527ad4ac5y9ktmurnmi8ck9dv91qmvj7pszr7ah37j&ep=v1_internal_gif_by_id&rid=200.gif&ct=g) -✨ Your new, shiny [Nx workspace](https://nx.dev) is almost ready ✨. +So, you spent all your money on records, and now you can't decide which one to listen to next... -[Learn more about this workspace setup and its capabilities](https://nx.dev/nx-api/nest?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed! +Spin Cycle sends you a randomly selected record (or CD! or cassette! or whatever!) from your collection on Discogs every morning. -## Finish your CI setup +- We make sure to cycle through your entire collection before we send a duplicate +- You can select a specific sub-collection in Discogs for recommendations +- Tell us you didn't listen to something and we'll put it back in the mix -[Click here to finish setting up your workspace!](https://cloud.nx.app/connect/kQxZUV0S1Q) +### TODO: - -## Run tasks - -To run the dev server for your app, use: - -```sh -npx nx serve spin-cycle -``` - -To create a production bundle: - -```sh -npx nx build spin-cycle -``` - -To see all available targets to run for a project, run: - -```sh -npx nx show project spin-cycle -``` - -These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files. - -[More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) - -## Add new projects - -While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature. - -Use the plugin's generator to create new projects. - -To generate a new application, use: - -```sh -npx nx g @nx/nest:app demo -``` - -To generate a new library, use: - -```sh -npx nx g @nx/node:lib mylib -``` - -You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list ` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE. - -[Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) - - -[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) - -## Install Nx Console - -Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ. - -[Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) - -## Useful links - -Learn more: - -- [Learn more about this workspace setup](https://nx.dev/nx-api/nest?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) -- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) -- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) -- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) - -And join the Nx community: -- [Discord](https://go.nx.dev/community) -- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl) -- [Our Youtube channel](https://www.youtube.com/@nxdevtools) -- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [x] Authentication with Discogs +- [ ] Messaging (SMTP) +- [ ] Scheduling +- [ ] History UI +- [ ] Settings UI (custom time, collection) +- [ ] Donation-based SMS option (?) diff --git a/apps/spin-cycle-client/eslint.config.cjs b/apps/spin-cycle-client/eslint.config.cjs new file mode 100644 index 0000000..fc5a983 --- /dev/null +++ b/apps/spin-cycle-client/eslint.config.cjs @@ -0,0 +1,34 @@ +const nx = require('@nx/eslint-plugin'); +const baseConfig = require('../../eslint.config.cjs'); + +module.exports = [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'sc', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'sc', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/apps/spin-cycle-client/project.json b/apps/spin-cycle-client/project.json new file mode 100644 index 0000000..d1aae91 --- /dev/null +++ b/apps/spin-cycle-client/project.json @@ -0,0 +1,95 @@ +{ + "name": "spin-cycle-client", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/spin-cycle-client/src", + "tags": [], + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/spin-cycle-client", + "index": "apps/spin-cycle-client/src/index.html", + "browser": "apps/spin-cycle-client/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/spin-cycle-client/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "apps/spin-cycle-client/public" + } + ], + "styles": ["apps/spin-cycle-client/src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kb", + "maximumError": "8kb" + } + ], + "fileReplacements": [ + { + "replace": "environments/environment.ts", + "with": "environments/environment.prod.ts" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "spin-cycle-client:build:production" + }, + "development": { + "buildTarget": "spin-cycle-client:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "spin-cycle-client:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../coverage/apps/spin-cycle-client" + } + }, + "serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "spin-cycle-client:build", + "staticFilePath": "dist/apps/spin-cycle-client/browser", + "spa": true + } + } + } +} diff --git a/apps/spin-cycle-client/public/favicon.ico b/apps/spin-cycle-client/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..317ebcb2336e0833a22dddf0ab287849f26fda57 GIT binary patch literal 15086 zcmeI332;U^%p|z7g|#(P)qFEA@4f!_@qOK2 z_lJl}!lhL!VT_U|uN7%8B2iKH??xhDa;*`g{yjTFWHvXn;2s{4R7kH|pKGdy(7z!K zgftM+Ku7~24TLlh(!g)gz|foI94G^t2^IO$uvX$3(OR0<_5L2sB)lMAMy|+`xodJ{ z_Uh_1m)~h?a;2W{dmhM;u!YGo=)OdmId_B<%^V^{ovI@y`7^g1_V9G}*f# zNzAtvou}I!W1#{M^@ROc(BZ! z+F!!_aR&Px3_reO(EW+TwlW~tv*2zr?iP7(d~a~yA|@*a89IUke+c472NXM0wiX{- zl`UrZC^1XYyf%1u)-Y)jj9;MZ!SLfd2Hl?o|80Su%Z?To_=^g_Jt0oa#CT*tjx>BI z16wec&AOWNK<#i0Qd=1O$fymLRoUR*%;h@*@v7}wApDl^w*h}!sYq%kw+DKDY)@&A z@9$ULEB3qkR#85`lb8#WZw=@})#kQig9oqy^I$dj&k4jU&^2(M3q{n1AKeGUKPFbr z1^<)aH;VsG@J|B&l>UtU#Ejv3GIqERzYgL@UOAWtW<{p#zy`WyJgpCy8$c_e%wYJL zyGHRRx38)HyjU3y{-4z6)pzb>&Q1pR)B&u01F-|&Gx4EZWK$nkUkOI|(D4UHOXg_- zw{OBf!oWQUn)Pe(=f=nt=zkmdjpO^o8ZZ9o_|4tW1ni+Un9iCW47*-ut$KQOww!;u z`0q)$s6IZO!~9$e_P9X!hqLxu`fpcL|2f^I5d4*a@Dq28;@2271v_N+5HqYZ>x;&O z05*7JT)mUe&%S0@UD)@&8SmQrMtsDfZT;fkdA!r(S=}Oz>iP)w=W508=Rc#nNn7ym z1;42c|8($ALY8#a({%1#IXbWn9-Y|0eDY$_L&j{63?{?AH{);EzcqfydD$@-B`Y3<%IIj7S7rK_N}je^=dEk%JQ4c z!tBdTPE3Tse;oYF>cnrapWq*o)m47X1`~6@(!Y29#>-#8zm&LXrXa(3=7Z)ElaQqj z-#0JJy3Fi(C#Rx(`=VXtJ63E2_bZGCz+QRa{W0e2(m3sI?LOcUBx)~^YCqZ{XEPX)C>G>U4tfqeH8L(3|pQR*zbL1 zT9e~4Tb5p9_G}$y4t`i*4t_Mr9QYvL9C&Ah*}t`q*}S+VYh0M6GxTTSXI)hMpMpIq zD1ImYqJLzbj0}~EpE-aH#VCH_udYEW#`P2zYmi&xSPs_{n6tBj=MY|-XrA;SGA_>y zGtU$?HXm$gYj*!N)_nQ59%lQdXtQZS3*#PC-{iB_sm+ytD*7j`D*k(P&IH2GHT}Eh z5697eQECVIGQAUe#eU2I!yI&%0CP#>%6MWV z@zS!p@+Y1i1b^QuuEF*13CuB zu69dve5k7&Wgb+^s|UB08Dr3u`h@yM0NTj4h7MnHo-4@xmyr7(*4$rpPwsCDZ@2be zRz9V^GnV;;?^Lk%ynzq&K(Aix`mWmW`^152Hoy$CTYVehpD-S1-W^#k#{0^L`V6CN+E z!w+xte;2vu4AmVNEFUOBmrBL>6MK@!O2*N|2=d|Y;oN&A&qv=qKn73lDD zI(+oJAdgv>Yr}8(&@ZuAZE%XUXmX(U!N+Z_sjL<1vjy1R+1IeHt`79fnYdOL{$ci7 z%3f0A*;Zt@ED&Gjm|OFTYBDe%bbo*xXAQsFz+Q`fVBH!N2)kaxN8P$c>sp~QXnv>b zwq=W3&Mtmih7xkR$YA)1Yi?avHNR6C99!u6fh=cL|KQ&PwF!n@ud^n(HNIImHD!h87!i*t?G|p0o+eelJ?B@A64_9%SBhNaJ64EvKgD&%LjLCYnNfc; znj?%*p@*?dq#NqcQFmmX($wms@CSAr9#>hUR^=I+=0B)vvGX%T&#h$kmX*s=^M2E!@N9#m?LhMvz}YB+kd zG~mbP|D(;{s_#;hsKK9lbVK&Lo734x7SIFJ9V_}2$@q?zm^7?*XH94w5Qae{7zOMUF z^?%F%)c1Y)Q?Iy?I>knw*8gYW#ok|2gdS=YYZLiD=CW|Nj;n^x!=S#iJ#`~Ld79+xXpVmUK^B(xO_vO!btA9y7w3L3-0j-y4 z?M-V{%z;JI`bk7yFDcP}OcCd*{Q9S5$iGA7*E1@tfkyjAi!;wP^O71cZ^Ep)qrQ)N z#wqw0_HS;T7x3y|`P==i3hEwK%|>fZ)c&@kgKO1~5<5xBSk?iZV?KI6&i72H6S9A* z=U(*e)EqEs?Oc04)V-~K5AUmh|62H4*`UAtItO$O(q5?6jj+K^oD!04r=6#dsxp?~}{`?&sXn#q2 zGuY~7>O2=!u@@Kfu7q=W*4egu@qPMRM>(eyYyaIE<|j%d=iWNdGsx%c!902v#ngNg z@#U-O_4xN$s_9?(`{>{>7~-6FgWpBpqXb`Ydc3OFL#&I}Irse9F_8R@4zSS*Y*o*B zXL?6*Aw!AfkNCgcr#*yj&p3ZDe2y>v$>FUdKIy_2N~}6AbHc7gA3`6$g@1o|dE>vz z4pl(j9;kyMsjaw}lO?(?Xg%4k!5%^t#@5n=WVc&JRa+XT$~#@rldvN3S1rEpU$;XgxVny7mki3 z-Hh|jUCHrUXuLr!)`w>wgO0N%KTB-1di>cj(x3Bav`7v z3G7EIbU$z>`Nad7Rk_&OT-W{;qg)-GXV-aJT#(ozdmnA~Rq3GQ_3mby(>q6Ocb-RgTUhTN)))x>m&eD;$J5Bg zo&DhY36Yg=J=$Z>t}RJ>o|@hAcwWzN#r(WJ52^g$lh^!63@hh+dR$&_dEGu&^CR*< z!oFqSqO@>xZ*nC2oiOd0eS*F^IL~W-rsrO`J`ej{=ou_q^_(<$&-3f^J z&L^MSYWIe{&pYq&9eGaArA~*kA +
+ @if (!authenticated()) { + Login + } +
+ + + diff --git a/apps/spin-cycle-client/src/app/app.component.scss b/apps/spin-cycle-client/src/app/app.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spin-cycle-client/src/app/app.component.spec.ts b/apps/spin-cycle-client/src/app/app.component.spec.ts new file mode 100644 index 0000000..251831b --- /dev/null +++ b/apps/spin-cycle-client/src/app/app.component.spec.ts @@ -0,0 +1,30 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'spin-cycle' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('spin-cycle'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, spin-cycle'); + }); +}); diff --git a/apps/spin-cycle-client/src/app/app.component.ts b/apps/spin-cycle-client/src/app/app.component.ts new file mode 100644 index 0000000..2589d2a --- /dev/null +++ b/apps/spin-cycle-client/src/app/app.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit, Signal, inject } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +import { AuthService } from './auth/auth.service'; + +@Component({ + selector: 'sc-root', + standalone: true, + imports: [RouterOutlet], + templateUrl: './app.component.html', + styleUrl: './app.component.scss', +}) +export class AppComponent implements OnInit { + private readonly authService: AuthService = inject(AuthService); + + public readonly authUrl: string = this.authService.authUrl; + public readonly authenticated: Signal = this.authService.authenticated; + + ngOnInit(): void { + this.authService.restoreToken(); + } +} diff --git a/apps/spin-cycle-client/src/app/app.config.ts b/apps/spin-cycle-client/src/app/app.config.ts new file mode 100644 index 0000000..5c65838 --- /dev/null +++ b/apps/spin-cycle-client/src/app/app.config.ts @@ -0,0 +1,14 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { authInterceptor } from './auth/auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + ], +}; diff --git a/apps/spin-cycle-client/src/app/app.routes.ts b/apps/spin-cycle-client/src/app/app.routes.ts new file mode 100644 index 0000000..68aa8f1 --- /dev/null +++ b/apps/spin-cycle-client/src/app/app.routes.ts @@ -0,0 +1,25 @@ +import { Routes } from '@angular/router'; + +import { AuthComponent } from './auth/auth.component'; +import { AuthGuard } from './auth/auth.guard'; +import { UnauthGuard } from './auth/unauth.guard'; +import { HistoryComponent } from './history/history.component'; +import { SettingsComponent } from './settings/settings.component'; + +export const routes: Routes = [ + { + path: 'auth', + component: AuthComponent, + canActivate: [UnauthGuard], + }, + { + path: 'settings', + component: SettingsComponent, + canActivate: [AuthGuard], + }, + { + path: 'history', + component: HistoryComponent, + canActivate: [AuthGuard], + }, +]; diff --git a/apps/spin-cycle-client/src/app/auth/auth.component.html b/apps/spin-cycle-client/src/app/auth/auth.component.html new file mode 100644 index 0000000..e69de29 diff --git a/apps/spin-cycle-client/src/app/auth/auth.component.scss b/apps/spin-cycle-client/src/app/auth/auth.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spin-cycle-client/src/app/auth/auth.component.spec.ts b/apps/spin-cycle-client/src/app/auth/auth.component.spec.ts new file mode 100644 index 0000000..98ad273 --- /dev/null +++ b/apps/spin-cycle-client/src/app/auth/auth.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AuthComponent } from './auth.component'; + +describe('AuthComponent', () => { + let component: AuthComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AuthComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AuthComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/spin-cycle-client/src/app/auth/auth.component.ts b/apps/spin-cycle-client/src/app/auth/auth.component.ts new file mode 100644 index 0000000..dadd9e5 --- /dev/null +++ b/apps/spin-cycle-client/src/app/auth/auth.component.ts @@ -0,0 +1,34 @@ +import { Component, DestroyRef, OnInit, WritableSignal, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; + +import { AuthService } from './auth.service'; + +@Component({ + selector: 'sc-auth', + standalone: true, + imports: [], + templateUrl: './auth.component.html', + styleUrl: './auth.component.scss', +}) +export class AuthComponent implements OnInit { + private readonly authService: AuthService = inject(AuthService); + private readonly destroyRef: DestroyRef = inject(DestroyRef); + private readonly route: ActivatedRoute = inject(ActivatedRoute); + private readonly router: Router = inject(Router); + + public readonly error: WritableSignal = signal(null); + + ngOnInit(): void { + this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: ParamMap) => { + const token = params.get('token'); + if (!token) { + this.error.set('Unable to authenticate'); + return; + } + + this.authService.setToken(token); + this.router.navigate(['/history']); + }); + } +} diff --git a/apps/spin-cycle-client/src/app/auth/auth.guard.ts b/apps/spin-cycle-client/src/app/auth/auth.guard.ts new file mode 100644 index 0000000..8386861 --- /dev/null +++ b/apps/spin-cycle-client/src/app/auth/auth.guard.ts @@ -0,0 +1,8 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +import { AuthService } from './auth.service'; + +export const AuthGuard: CanActivateFn = () => { + return inject(AuthService).authenticated() ? true : inject(Router).createUrlTree(['/auth']); +}; diff --git a/apps/spin-cycle-client/src/app/auth/auth.interceptor.ts b/apps/spin-cycle-client/src/app/auth/auth.interceptor.ts new file mode 100644 index 0000000..5c812e3 --- /dev/null +++ b/apps/spin-cycle-client/src/app/auth/auth.interceptor.ts @@ -0,0 +1,24 @@ +import { HttpEvent, HttpHandlerFn, HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable, catchError, of } from 'rxjs'; + +import { AuthService } from './auth.service'; + +export function authInterceptor(req: HttpRequest, next: HttpHandlerFn): Observable> { + const authService: AuthService = inject(AuthService); + const processed: HttpRequest = + authService.authenticated() && authService.getToken() + ? req.clone({ headers: new HttpHeaders({ Authorization: `Bearer ${authService.getToken()}` }) }) + : req; + + return next(processed).pipe( + catchError((err: HttpResponse) => { + if (err.status === 401) { + authService.revokeToken(); + inject(Router).navigate(['/login']); + } + return of(err); + }), + ); +} diff --git a/apps/spin-cycle-client/src/app/auth/auth.service.ts b/apps/spin-cycle-client/src/app/auth/auth.service.ts new file mode 100644 index 0000000..36cce67 --- /dev/null +++ b/apps/spin-cycle-client/src/app/auth/auth.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Signal, WritableSignal, inject, signal } from '@angular/core'; + +import { environment } from '../../environments/environment'; +import { StorageService } from '../storage.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + private readonly storageService: StorageService = inject(StorageService); + + private readonly _authenticated: WritableSignal = signal(false); + public authenticated: Signal = this._authenticated.asReadonly(); + + public readonly authUrl: string = `${environment.apiUrl}/auth`; + + public getToken(): string | null { + return this.storageService.get(environment.localStorageTokenKey); + } + + public setToken(token: string): void { + this.storageService.set(environment.localStorageTokenKey, token); + this._authenticated.set(true); + } + + public revokeToken(): void { + this.storageService.remove(environment.localStorageTokenKey); + this._authenticated.set(false); + } + + public restoreToken(): void { + const token: string | null = this.storageService.get(environment.localStorageTokenKey); + this._authenticated.set(!!token); + } +} diff --git a/apps/spin-cycle-client/src/app/auth/unauth.guard.ts b/apps/spin-cycle-client/src/app/auth/unauth.guard.ts new file mode 100644 index 0000000..4d02680 --- /dev/null +++ b/apps/spin-cycle-client/src/app/auth/unauth.guard.ts @@ -0,0 +1,8 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +import { AuthService } from './auth.service'; + +export const UnauthGuard: CanActivateFn = () => { + return inject(AuthService).authenticated() ? inject(Router).createUrlTree(['/settings']) : true; +}; diff --git a/apps/spin-cycle-client/src/app/history/history.component.html b/apps/spin-cycle-client/src/app/history/history.component.html new file mode 100644 index 0000000..e91e10e --- /dev/null +++ b/apps/spin-cycle-client/src/app/history/history.component.html @@ -0,0 +1 @@ +

history works!

diff --git a/apps/spin-cycle-client/src/app/history/history.component.scss b/apps/spin-cycle-client/src/app/history/history.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spin-cycle-client/src/app/history/history.component.spec.ts b/apps/spin-cycle-client/src/app/history/history.component.spec.ts new file mode 100644 index 0000000..90f0f6a --- /dev/null +++ b/apps/spin-cycle-client/src/app/history/history.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HistoryComponent } from './history.component'; + +describe('HistoryComponent', () => { + let component: HistoryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HistoryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HistoryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/spin-cycle-client/src/app/history/history.component.ts b/apps/spin-cycle-client/src/app/history/history.component.ts new file mode 100644 index 0000000..1a891c4 --- /dev/null +++ b/apps/spin-cycle-client/src/app/history/history.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'sc-history', + standalone: true, + imports: [], + templateUrl: './history.component.html', + styleUrl: './history.component.scss', +}) +export class HistoryComponent {} diff --git a/apps/spin-cycle-client/src/app/settings.service.spec.ts b/apps/spin-cycle-client/src/app/settings.service.spec.ts new file mode 100644 index 0000000..10a7c22 --- /dev/null +++ b/apps/spin-cycle-client/src/app/settings.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SettingsService } from './settings/settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SettingsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/spin-cycle-client/src/app/settings/settings.component.html b/apps/spin-cycle-client/src/app/settings/settings.component.html new file mode 100644 index 0000000..c7ee705 --- /dev/null +++ b/apps/spin-cycle-client/src/app/settings/settings.component.html @@ -0,0 +1,17 @@ +
+

Settings

+ +
+
+ + +
+ +
+ + +
+ + +
+
diff --git a/apps/spin-cycle-client/src/app/settings/settings.component.scss b/apps/spin-cycle-client/src/app/settings/settings.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spin-cycle-client/src/app/settings/settings.component.spec.ts b/apps/spin-cycle-client/src/app/settings/settings.component.spec.ts new file mode 100644 index 0000000..83f8f7e --- /dev/null +++ b/apps/spin-cycle-client/src/app/settings/settings.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SettingsComponent } from './settings.component'; + +describe('SettingsComponent', () => { + let component: SettingsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SettingsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/spin-cycle-client/src/app/settings/settings.component.ts b/apps/spin-cycle-client/src/app/settings/settings.component.ts new file mode 100644 index 0000000..30deb1e --- /dev/null +++ b/apps/spin-cycle-client/src/app/settings/settings.component.ts @@ -0,0 +1,46 @@ +import { Component, OnInit, WritableSignal, inject, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserOut } from '@spin-cycle-mono/shared'; + +import { SettingsService } from './settings.service'; + +interface IUserForm { + email: FormControl; +} + +@Component({ + selector: 'sc-settings', + standalone: true, + imports: [ReactiveFormsModule], + templateUrl: './settings.component.html', + styleUrl: './settings.component.scss', +}) +export class SettingsComponent implements OnInit { + private readonly settingsService: SettingsService = inject(SettingsService); + + public readonly settings: WritableSignal = signal(null); + + public readonly form: FormGroup = new FormGroup({ + email: new FormControl(null, [Validators.required]), + }); + + ngOnInit(): void { + this.settingsService.getUserSettings().subscribe((settings: UserOut) => { + this.settings.set(settings); + + this.form.controls.email.setValue(settings.email); + this.form.markAsPristine(); + }); + } + + save(): void { + const settings: UserOut | null = this.settings(); + if (this.form.valid && settings) { + this.settingsService + .updateSettings(settings.id, { email: this.form.controls.email.value }) + .subscribe((settings: UserOut) => { + this.settings.set(settings); + }); + } + } +} diff --git a/apps/spin-cycle-client/src/app/settings/settings.service.ts b/apps/spin-cycle-client/src/app/settings/settings.service.ts new file mode 100644 index 0000000..5de4ceb --- /dev/null +++ b/apps/spin-cycle-client/src/app/settings/settings.service.ts @@ -0,0 +1,25 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { UserOut } from '@spin-cycle-mono/shared'; +import { Observable, map } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class SettingsService { + private readonly http: HttpClient = inject(HttpClient); + + getUserSettings(): Observable { + return this.http + .get(`${environment.apiUrl}/settings`) + .pipe(map((partial: UserOut) => new UserOut(partial.id, partial.discogsId, partial.username, partial.email))); + } + + updateSettings(id: string, params: { email: string | null }): Observable { + return this.http + .patch(`${environment.apiUrl}/settings/${id}`, params) + .pipe(map((partial: UserOut) => new UserOut(partial.id, partial.discogsId, partial.username, partial.email))); + } +} diff --git a/apps/spin-cycle-client/src/app/storage.service.spec.ts b/apps/spin-cycle-client/src/app/storage.service.spec.ts new file mode 100644 index 0000000..e7fe5b5 --- /dev/null +++ b/apps/spin-cycle-client/src/app/storage.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { StorageService } from './storage.service'; + +describe('StorageService', () => { + let service: StorageService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(StorageService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/spin-cycle-client/src/app/storage.service.ts b/apps/spin-cycle-client/src/app/storage.service.ts new file mode 100644 index 0000000..a024381 --- /dev/null +++ b/apps/spin-cycle-client/src/app/storage.service.ts @@ -0,0 +1,44 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, inject } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class StorageService { + private readonly localStorage = inject(DOCUMENT)?.defaultView?.localStorage; + + get(key: string): T | null { + const item = this.localStorage?.getItem(key); + + if (!item) { + return null; + } + + return this.isJSONValid(item) ? (JSON.parse(item) as T) : (item as T); + } + + set(key: string, value: unknown): void { + this.localStorage?.setItem(key, JSON.stringify(value)); + } + + remove(key: string): void { + this.localStorage?.removeItem(key); + } + + removeKeys(keys: string[]): void { + keys.forEach((key) => this.localStorage?.removeItem(key)); + } + + clear(): void { + this.localStorage?.clear(); + } + + private isJSONValid(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch (error) { + return false; + } + } +} diff --git a/apps/spin-cycle-client/src/app/window-ref.ts b/apps/spin-cycle-client/src/app/window-ref.ts new file mode 100644 index 0000000..1a6b38c --- /dev/null +++ b/apps/spin-cycle-client/src/app/window-ref.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; + +export const WindowRef = new InjectionToken('Global window object', { + factory: () => window, +}); diff --git a/apps/spin-cycle-client/src/environments/environment.prod.ts b/apps/spin-cycle-client/src/environments/environment.prod.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/spin-cycle-client/src/environments/environment.ts b/apps/spin-cycle-client/src/environments/environment.ts new file mode 100644 index 0000000..0150aa8 --- /dev/null +++ b/apps/spin-cycle-client/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + apiUrl: 'http://localhost:3000', + localStorageTokenKey: 'spinCycleAuthToken', +}; diff --git a/apps/spin-cycle-client/src/index.html b/apps/spin-cycle-client/src/index.html new file mode 100644 index 0000000..a10d9b8 --- /dev/null +++ b/apps/spin-cycle-client/src/index.html @@ -0,0 +1,13 @@ + + + + + spin-cycle-client + + + + + + + + diff --git a/apps/spin-cycle-client/src/main.ts b/apps/spin-cycle-client/src/main.ts new file mode 100644 index 0000000..460bf93 --- /dev/null +++ b/apps/spin-cycle-client/src/main.ts @@ -0,0 +1,9 @@ +import { bootstrapApplication } from '@angular/platform-browser'; + +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => { + console.log('ah shit..'); + console.error(err); +}); diff --git a/apps/spin-cycle-client/src/styles.scss b/apps/spin-cycle-client/src/styles.scss new file mode 100644 index 0000000..49bbc1d --- /dev/null +++ b/apps/spin-cycle-client/src/styles.scss @@ -0,0 +1,2 @@ +@import './styles/boilerplate.css'; +@import 'styles/custom'; diff --git a/apps/spin-cycle-client/src/styles/_custom.scss b/apps/spin-cycle-client/src/styles/_custom.scss new file mode 100644 index 0000000..a7a685c --- /dev/null +++ b/apps/spin-cycle-client/src/styles/_custom.scss @@ -0,0 +1,7 @@ +.container { + margin: 20px; +} + +.margin-bottom { + margin-bottom: 20px; +} diff --git a/apps/spin-cycle-client/src/styles/boilerplate.css b/apps/spin-cycle-client/src/styles/boilerplate.css new file mode 100644 index 0000000..3871b85 --- /dev/null +++ b/apps/spin-cycle-client/src/styles/boilerplate.css @@ -0,0 +1,1408 @@ +/* You can add global styles to this file, and also import other style files */ +/* ================================================================== + CSS Boilerplate v2.0.2 + The Unlicense + https://github.com/MattMcAdams/CSS-Boilerplate +================================================================== */ +/* ================================================================= +/* SECTION Tokens +================================================================= */ + +:root { + /* Spacing */ + --space_05: 0.25rem; /* 4px - utility to substitute 0 margins */ + --space_1: 0.5rem; /* 8px - utility for tight paddings etc */ + --space_2: 1rem; /* 16px - 1/2 line height, for closely related elements */ + --space_4: 2rem; /* 32px - 1 line height, for related elements */ + --space_8: 4rem; /* 64px - 2 line heights, for loosely related elements */ + --space_16: 8rem; /* 128px - 4 line heights, for unrelated elements */ + + /* Semantic Space */ + --space_flow: var(--space_4); + --space_gutter: var(--space_2); + --space_section: var(--space_8); + --space_grid-gap: var(--space_4); + --space_flex-gap: var(--space_2); + --space_layout-gap: var(--space_4); + + /* Layout Widths */ + --width_content: 60rem; /* 960px + gutters */ + --width_sidebar: 20rem; /* 320px + gutters */ + --width_wide: calc(var(--width_content) + var(--width_sidebar) + var(--space_layout-gap)); + /* Total wide width: 60rem + 20rem + 2rem = 82rem */ + + /* Colors */ + --color_text--default: #18181b; + --color_text--subtle: #71717a; + --color_text--link: #6366f1; + --color_text--link-alt: #4f46e5; + --color_text--accent: var(--color_text--link); + --color_background--surface: white; + --color_background--element: #f4f4f5; + --color_background--chip: #e4e4e7; + --color_accent: var(--color_text--link); + + /* Font Families */ + --font_body: system-ui, sans-serif; + --font_head: inherit; + --font_mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; + + /* Font Weights */ + --font_weight--thin: 100; + --font_weight--extralight: 200; + --font_weight--light: 300; + --font_weight--normal: 400; + --font_weight--medium: 500; + --font_weight--semibold: 600; + --font_weight--bold: 700; + --font_weight--extrabold: 800; + --font_weight--black: 900; + + /* Font Sizes & Line Heights */ + /* Based on a modular scale 1.125 by 1.2 */ + /* Exact values have been refined to be a whole pixel value */ + --font_size--small: 0.9375rem; /* 15px */ + --font_size--normal: 1.125rem; /* 18px */ + --font_size--medium: 1.375rem; /* 22px */ + --font_size--large: 1.625rem; /* 26px */ + --font_size--xlarge: 1.9375rem; /* 31px */ + --font_size--xxlarge: 2.3125rem; /* 37px */ + --font_size--xxxlarge: 2.8125rem; /* 45px */ + + /* Line heights */ + --font_height--small: 1.2; + --font_height--normal: 1.8; + --font_height--medium: 1.5; + --font_height--large: 1.3; + --font_height--xlarge: 1.2; + --font_height--xxlarge: 1.1; + --font_height--xxxlarge: 1; +} + +/* Medium font sizes */ +/* Based on a modular scale 1.125 by 1.25 */ +/* Exact values have been refined to be a whole pixel value */ +@media (min-width: 30rem) { + :root { + --font_size--small: 0.875rem; /* 14px */ + --font_size--medium: 1.4375rem; /* 23px */ + --font_size--large: 1.75rem; /* 28px */ + --font_size--xlarge: 2.1875rem; /* 35px */ + --font_size--xxlarge: 2.75rem; /* 44px */ + --font_size--xxxlarge: 3.4375rem; /* 55px */ + } +} + +/* Large font sizes */ +/* Based on a modular scale 1.125 by 1.3 */ +/* Exact values have been refined to be a whole pixel value */ +@media (min-width: 60em) { + :root { + --font_size--large: 1.875rem; /* 30px */ + --font_size--xlarge: 2.5rem; /* 40px */ + --font_size--xxlarge: 3.1875rem; /* 51px */ + --font_size--xxxlarge: 4.1875rem; /* 67px */ + } +} + +del { + --color_background--element: #fdebeb; +} + +ins { + --color_background--element: #e3fcec; +} + +/* !SECTION Tokens */ +/* ================================================================= +/* SECTION Reset +** http://meyerweb.com/eric/tools/css/reset/ +** v2.0 | 20110126 * License: none (public domain) +** Modified by Matt McAdams +================================================================= */ + +* { + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video, +hr { + margin: 0; + padding: 0; + border: 0; + font: inherit; + font-size: 100%; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} + +body { + line-height: 1; +} + +ol, +ul { + list-style: none; +} + +blockquote, +q { + quotes: none; +} + +blockquote::before, +blockquote::after, +q::before, +q::after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +/* END !SECTION Reset */ +/* ================================================================= +/* SECTION Core +================================================================= */ + +/* Links that point to a location on the same page will scroll + * smoothly down to that location. */ + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +} + +/* Hide content visually, but allow screen readers to read the + * content. Note that for Accessibility guidelines, this content + * must become visible if toggled over using a keyboard. */ + +.visually-hidden:not(:focus):not(:active) { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +/* Give the hidden attribute most priority - fixes issue where + * an html element can be marked as hidden but is still visible. + * Use aria-hidden="true" to hide visual elements from + * screen readers. */ + +[hidden] { + display: none !important; +} + +/* Ensures disabled elements do not accept events */ + +[disabled] { + pointer-events: none !important; + cursor: not-allowed !important; +} + +/* Use primary color for focus styles */ + +:focus-visible { + outline-color: var(--color_accent); + outline-offset: 3px; + outline-width: 2px; +} + +/* Adds a margin above an element when it is the target of an ID link */ + +* { + scroll-margin-top: var(--space_flow); + scroll-margin-bottom: var(--space_flow); +} + +/* ::selection { + background-color: var(--color-primary-100); +} */ + +/* ============================== +/* SECTION Typography +============================== */ + +/* Set the document's default font, color, size, and line height */ + +body { + font-family: var(--font_body); + font-size: var(--font_size--normal); + line-height: var(--font_height--normal); + color: var(--color_text--default); + background: var(--color_background--surface); + accent-color: var(--color_accent); +} + +/* Heading typography */ + +h1, +h2, +h3, +h4, +h5 { + font-family: var(--font_head); + font-weight: var(--font_weight--bold); +} + +h1, +.util_txt--h1 { + font-size: var(--font_size--xxxlarge); + line-height: var(--font_height--xxxlarge); +} + +h2, +.util_txt--h2 { + font-size: var(--font_size--xxlarge); + line-height: var(--font_height--xxlarge); +} + +h3, +.util_txt--h3 { + font-size: var(--font_size--xlarge); + line-height: var(--font_height--xlarge); +} + +h4, +.util_txt--h4 { + font-size: var(--font_size--large); + line-height: var(--font_height--large); +} + +h5 { + font-size: var(--font_size--medium); + line-height: var(--font_height--medium); +} + +/* Set typography for small text */ + +small, +.util_txt--small { + font-size: var(--font_size--small); + line-height: var(--font_height--small); +} + +/* Basic styles for inline semantics */ + +strong, +b { + font-weight: bold; +} +em, +cite, +i, +q { + font-style: italic; +} +s { + text-decoration: line-through; +} +u { + text-decoration: underline; + font-style: normal; +} + +/* Basic style for subscript and superscript text */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; +} + +sup { + top: -0.5em; +} +sub { + bottom: -0.25em; +} + +del { + background: var(--color_background--element); + text-decoration: line-through; + padding: 0.1em 0.3em; +} + +ins { + background: var(--color_background--element); + text-decoration: underline; + padding: 0.1em 0.3em; +} + +/* Mark styles - see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/mark */ + +mark { + background: var(--color_text--accent); + color: var(--color_background--surface); + padding: 0.1em 0.3em; +} + +/* Basic styles for abbreviation. Only style differently when + * a title is present. */ + +abbr { + text-decoration: none; +} +abbr[title] { + cursor: help; + text-decoration: underline; + text-decoration-style: dotted; +} + +/* !SECTION Typography */ +/* ============================== +/* SECTION Links +** :not([class]) is so that the styles won't need to be overridden +** in special use cases like navigation / buttons +============================== */ + +a:not([class]) { + color: var(--color_text--link); + text-decoration: none; + font-weight: bold; +} + +/* a:not([class]):visited { } */ + +a:not([class]):hover, +a:not([class]):focus { + text-decoration: underline; + text-decoration-color: var(--color_text--link-alt); + text-decoration-thickness: 2px; +} + +/* OPTIONAL STYLES - Add an icon for special links */ + +/* a:not([class])[target="_blank"]::after, +a:not([class])[data-link-type="external"] { + content: ''; + padding-inline-start: 0.1em; +} */ + +/* a:not([class])[href$='.pdf']::after, +a:not([class])[data-link-type='document']::after { + content: ''; + padding-inline-start: 0.1em; +} */ + +/* a:not([class])[href^="tel:"]::before, +a:not([class])[data-link-type="telephone"]::before { + content: ''; + padding-inline-end: 0.1em; +} */ + +/* a:not([class])[href^="mailto:"]::before, +a:not([class])[data-link-type="email"]::before { + content: ''; + padding-inline-end: 0.1em; +} */ + +/* !SECTION Links */ +/* ============================== +/* SECTION Blockquote +============================== */ + +blockquote { + border-left: 5px solid var(--color_accent); + padding: var(--space_2) var(--space_4); + font-weight: var(--font_weight--semibold); +} + +blockquote footer, +blockquote cite { + font-size: var(--font_size--small); + line-height: var(--font_height--small); + font-weight: var(--font_weight--normal); +} + +blockquote cite { + font-style: italic; +} + +blockquote > cite, +blockquote > footer { + display: block; + margin-block-start: calc(var(--space_flow) / 2); +} + +/* !SECTION Blockquote */ +/* ============================== +/* SECTION Lists +** :not([class]) is so that the styles won't need to be overridden +** in special use cases. For example, when list markup might need +** to be paired with a grid layout +============================== */ + +/* Set nested unordered list styles */ + +ul:not([class]) { + list-style-type: disc; +} +ul:not([class]) ul:not([class]) { + list-style-type: circle; +} +ul:not([class]) ul:not([class]) ul:not([class]) { + list-style-type: square; +} + +/* Set nested ordered list styles */ + +ol:not([class]) { + list-style-type: decimal; +} +ol:not([class]) ol:not([class]) { + list-style-type: upper-alpha; +} +ol:not([class]) ol:not([class]) ol:not([class]) { + list-style-type: lower-roman; +} + +/* Set indention and flow spacing for lists */ + +ol:not([class]) li, +ul:not([class]) li { + margin-block-end: var(--space_1); + margin-inline-start: var(--space_4); +} + +/* Add basic styles for definition lists */ + +dt { + font-weight: bold; +} +dd { + padding-inline-start: var(--space_2); +} + +/* !SECTION Lists */ +/* ============================== +/* SECTION Media +============================== */ + +/* Allow media to sit correctly in content flow */ + +img, +figure, +video, +.aspect-ratio, +.embed-wrapper { + display: block; + width: 100%; +} + +img { + max-width: 100%; + width: auto; + height: auto; +} + +/* Add placeholder background for video */ + +video:not(:has(source)) { + background: var(--color_background--element); +} + +/* Basic style for figcaption */ + +figcaption { + display: block; + font-style: italic; +} + +/* Allow audio to sit correctly in content flow, + * Adjust border radius to allow consistency across browsers */ + +audio { + display: block; + width: 100%; + max-width: 100%; + border-radius: 900px; +} + +/* Setup media wrappers */ + +.aspect-ratio, +.embed-wrapper { + --aspect-ratio: 16/9; + width: 100%; + padding-block-start: calc(100% / (var(--aspect-ratio))); + position: relative; +} + +/* Position inner elements in media wrappers */ + +.embed-wrapper > iframe, +.embed-wrapper > embed, +.embed-wrapper > object, +.aspect-ratio > iframe, +.aspect-ratio > embed, +.aspect-ratio > object, +.aspect-ratio > img { + position: absolute; + inset: 0; + height: 100%; + width: 100%; +} + +/* Allow img to crop to avoid being stretched */ + +.aspect-ratio > img { + object-fit: cover; +} + +/* Wrapper to allow horizontal overflow, + * Useful for tables and other wide content */ + +.overflow-x, +.table-wrapper { + overflow-x: auto; +} + +/* !SECTION Media */ +/* ============================== +/* SECTION Rules +============================== */ + +hr { + border: none; + border-block-end: 1px solid var(--color_text--subtle); + width: 100%; +} + +hr.spacer { + --spacer-height: calc(var(--space_flow) * 2); + border: none; + margin-block-start: var(--spacer-height) 0; +} + +/* !SECTION Rules*/ +/* ============================== +/* SECTION Tables +============================== */ + +table { + width: 100%; +} + +caption { + font-size: inherit; + line-height: inherit; + text-align: start; + margin-block-end: var(--space_2); +} + +thead { + background-color: var(--color_text--default); + color: var(--color_background--surface); +} + +th, +td { + font-size: var(--font_size--small); + line-height: var(--font_height--small); + padding: var(--space_2); + text-align: start; +} + +th { + font-weight: bold; +} + +tr { + border-block-end: 0.5px solid var(--color_text--subtle); +} + +tbody tr:hover { + background-color: var(--color_background--element); +} + +tfoot { + background-color: var(--color_background--chip); +} + +/* !SECTION Tables */ +/* ============================== +/* SECTION Code +============================== */ + +code, +samp, +kbd, +var { + font-family: var(--font_mono); + padding: 0.1em 0.3em; +} + +code, +samp { + background-color: var(--color_background--element); +} + +var { + font-style: italic; + font-weight: bold; +} + +kbd { + background-color: var(--color_text--default); + color: var(--color_background--surface); + border-radius: 5px; +} + +pre code, +pre samp { + display: block; + padding: var(--space_2); + overflow: auto; + font-family: var(--font_mono); + font-size: var(--font_size--small); + line-height: var(--font_height--small); +} + +/* !SECTION Code */ +/* ============================== +/* SECTION Details +============================== */ + +/* Display:block removes Firefox's marker + * ::webkit-details-marker removed Chrome & Safair's marker */ + +details { + display: block; + position: relative; +} + +summary::-webkit-details-marker { + display: none; +} + +summary { + display: block; + cursor: pointer; + color: var(--color_text--default); + font-weight: var(--font_weight--bold); + font-weight: bold; +} + +summary::before { + content: '+'; + box-sizing: border-box; + text-align: center; + display: inline-block; + font-size: 1em; + width: 1em; + margin-inline-end: 0.5em; + will-change: transform; + transition: transform 300ms ease; +} + +summary:focus::before { + color: var(--color_accent); +} + +summary + * { + margin-block-start: calc(var(--space_flow) / 2); +} + +details[open] > summary::before { + transform: rotate(45deg); +} + +details { + background: var(--color_background--element); + padding: var(--space_2); +} + +/* !SECTION Details */ +/* ============================== +/* SECTION Meter +============================== */ + +meter { + --meter-color_track: var(--color_background--element); + --meter-color_optimum: #15803d; + --meter-color_sub-optimum: #f59e0b; + --meter-color_sub-sub-optimum: #dc2626; + + display: block; + width: 100%; + height: 1rem; + + -webkit-appearance: none; + border-radius: 0; + background: none; + background-color: var(--meter-color_track); +} + +/* Set the track color for webkit browsers */ + +meter::-webkit-meter-bar { + background: var(--meter-color_track); +} + +/* Set the optimum color */ + +meter::-webkit-meter-optimum-value { + background: var(--meter-color_optimum); +} + +meter:-moz-meter-optimum::-moz-meter-bar { + background: var(--meter-color_optimum); +} + +/* Set the sub optimum color */ + +meter:-moz-meter-sub-optimum::-moz-meter-bar { + background: var(--meter-color_sub-optimum); +} + +meter::-webkit-meter-suboptimum-value { + background: var(--meter-color_sub-optimum); +} + +/* Set the sub sub optimum color */ + +meter:-moz-meter-sub-sub-optimum::-moz-meter-bar { + background: var(--meter-color_sub-sub-optimum); +} + +/* The red (even less good) bar in Chrome etc. */ +meter::-webkit-meter-even-less-good-value { + background: var(--meter-color_sub-sub-optimum); +} + +/* !SECTION Meter */ +/* ============================== +/* SECTION Progress +============================== */ + +progress[value] { + --progress-color_track: var(--color_background--element); + --progress-color_fill: var(--color_accent); + + display: block; + width: 100%; + height: 0.25rem; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + border-radius: 0; + + background: var(--progress-color_track); +} + +progress[value]::-webkit-progress-bar { + background: var(--progress-color_track); +} + +progress[value]::-webkit-progress-value { + background: var(--progress-color_fill); +} + +progress[value]::-moz-progress-bar { + background: var(--progress-color_fill); +} + +/* !SECTION Progress */ +/* !SECTION Core */ +/* ================================================================= +/* SECTION Forms +** File uploads are not styled, you're better off implementing +** something with JS. +** Date & Color inputs are not styled, they're far too complicated +** for the scope of this project. +================================================================= */ + +/* Setup form specific styles and variables */ + +form { + --form_invalid-color: #dc2626; +} + +/* Add required input notice if required fields exist */ + +form:has(.form-field input[required])::after { + content: '* indicates a required field'; + color: var(--form_invalid-color); + margin-block-start: var(--space_flow); + display: block; +} + +/* Standardize labels */ + +label, +legend { + background-color: transparent; + font: inherit; +} + +/* Standard styles for normal inputs */ + +input:not([type='checkbox'], [type='radio'], [type='color']), +select, +textarea, +button { + font: inherit; + padding: var(--space_1); + display: block; + width: 100%; + border: 1px solid var(--color_text--subtle); + line-height: inherit; + box-sizing: border-box; + background: transparent; +} + +/* Remove border and padding for file inputs */ + +input[type='file'] { + padding: var(--space_1) 0; + border: none; +} + +/* Set textarea default height and restrict resize to vertical */ + +textarea { + resize: vertical; + min-height: 10rem; + font-family: var(--font_mono); + font-size: var(--font_size--small); + line-height: var(--font_height--small); +} + +/* ============================== +/* SECTION Form field class +============================== */ + +.form-field label { + display: block; +} + +/* Add required marker to labels if their input is required */ + +.form-field:has(input[required]) label::after { + content: ' *'; + color: var(--form_invalid-color); +} + +/* !SECTION Form field class */ +/* ============================== +/* SECTION Radio & Checkbox +============================== */ + +ul:has(input[type='checkbox']), +ul:has(input[type='radio']), +ol:has(input[type='checkbox']), +ol:has(input[type='radio']) { + list-style: none; +} + +ul:has(input[type='checkbox']) li, +ul:has(input[type='radio']) li, +ol:has(input[type='checkbox']) li, +ol:has(input[type='radio']) li { + margin: 0; +} + +/* !SECTION Radio & Checkbox */ +/* ============================== +/* SECTION Range +============================== */ + +input[type='range'], +input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + font-size: 1rem; + height: 1rem; + padding: 0; + border: none; +} + +/* Track Styles */ +input[type='range']::-webkit-slider-runnable-track { + border: none; + height: 5px; + border-radius: 0; + background-color: var(--color_background--element); +} + +input[type='range']::-moz-range-track { + height: 5px; + border: none; + border-radius: 0; + background-color: var(--color_background--element); +} + +input[type='range']::-moz-range-progress { + background-color: var(--color_accent); + height: 5px; +} + +/* Thumb Styles */ +input[type='range']::-webkit-slider-thumb { + margin-block-start: calc((1.2rem / -2) + 2.5px); + width: 1.2rem; + height: 1.2rem; + border: 2px solid var(--color_background--surface); + border-radius: 1000rem; + background: var(--color_accent); +} + +input[type='range']::-moz-range-thumb { + border: 2px solid var(--color_background--surface); + border-radius: 1000rem; + font-size: 1.2rem; + background: var(--color_accent); +} + +/* !SECTION Range */ +/* ============================== +/* SECTION Buttons +============================== */ + +.button, +.button--secondary, +button, +input[type='submit'], +input[type='reset'], +input[type='button'] { + display: inline-block; + width: auto; + text-align: center; + white-space: nowrap; + text-decoration: none; + background-color: var(--color_text--link); + cursor: pointer; + box-sizing: border-box; + color: var(--color_background--surface); + border: 2px solid var(--color_text--link); + padding-inline: var(--space_4); + padding-block: var(--space_1); +} + +a.button:hover, +a.button--secondary:hover, +button:hover, +input[type='submit']:hover, +input[type='reset']:hover, +input[type='button']:hover, +a.button:focus, +a.button--secondary:focus, +button:focus, +input[type='submit']:focus, +input[type='reset']:focus, +input[type='button']:focus { + border-color: var(--color_text--link-alt); + background-color: var(--color_text--link-alt); +} + +.button--secondary, +button[type='reset'], +input[type='reset'] { + background-color: var(--color_background--surface); + color: var(--color_text--link); + border-color: var(--color_text--link); +} + +a.button--secondary:hover, +button[type='reset']:hover, +input[type='reset']:hover, +a.button--secondary:focus, +button[type='reset']:focus, +input[type='reset']:focus { + background-color: var(--color_background--surface); + color: var(--color_text--link-alt); + border-color: var(--color_text--link-alt); +} + +span.button, +button[disabled], +input[type='submit'][disabled], +input[type='reset'][disabled], +input[type='button'][disabled] { + cursor: auto; + background-color: var(--color_background--chip); + color: var(--color_text--subtle); + border-color: var(--color_background--chip); +} + +span.button--secondary, +input[type='reset'][disabled], +button[type='reset'][disabled] { + cursor: auto; + background-color: var(--color_background--surface); + color: var(--color_background--chip); + border-color: var(--color_background--chip); +} + +/* !SECTION Buttons */ +/* !SECTION Forms */ +/* ================================================================= +/* SECTION Layout +================================================================= */ +/* ============================== +/* SECTION Container System +** Containers allow for a robust and flexible layout system by +** applying the gutter padding and max width to the container itself. +** Containers can be nested as needed. +============================== */ + +/* Setup containers */ + +.container { + margin-inline: auto; + padding-inline: var(--space_gutter); + max-width: var(--width_content); +} + +.container--wide { + margin-inline: auto; + padding-inline: var(--space_gutter); + max-width: var(--width_wide); +} + +.container--full { + margin-inline: auto; + padding-inline: var(--space_gutter); +} + +/* Allow nested containers wider than parent */ + +.container > .container--wide { + max-width: var(--width_wide); + margin-inline: calc(min(calc(100vw - 100%), calc(var(--width_wide) - 100%)) / -2); +} + +.container > .container--full, +.container--wide > .container--full { + max-width: 100vw; + margin-inline: calc((100vw - 100%) / -2); +} + +/* Remove duplicate gutters from nested containers the same size as parent */ + +.container > .container, +.container--wide > .container--wide, +.container--full > .container--full { + margin-inline: calc(var(--space_gutter) * -1); +} + +/* Account for containers smaller than parent */ + +.container--wide > .container, +.container--full > .container { + margin-inline: max(calc(var(--space_gutter) * -1), calc((100% - var(--width_content)) / 2)); +} + +.container--full > .container--wide { + margin-inline: max(calc(var(--space_gutter) * -1), calc((100% - var(--width_wide)) / 2)); +} + +/* !SECTION Containers */ +/* ============================== +/* SECTION Flow Spacing +** Flow spacing is the space between elements in a vertical flow. +** The .section class is provided for larger gaps between sections. +** Flow spacing can be applied to a individual container using the .layout--flow class. or to the entire document using the .global--flow class. The implementation is different for each method, so consider how you want the spacing to be applied. +============================== */ + +.layout--flow > * + *, +blockquote > * + *, +details > * + * { + margin-block-start: var(--space_flow); +} + +.layout--flow-double > * + * { + margin-block-start: calc(var(--space_flow) * 2); +} + +.layout--flow-half > * + * { + margin-block-start: calc(var(--space_flow) / 2); +} + +.layout--flow > * + h1, +.layout--flow > * + h2, +.layout--flow > * + h3, +.layout--flow > * + h4, +.layout--flow > * + h5, +.layout--flow > * + h6 { + margin-block-start: calc(var(--space_flow) * 2); +} + +.layout--flow > h1 + h2, +.layout--flow > h2 + h3, +.layout--flow > h3 + h4, +.layout--flow > h4 + h5, +.layout--flow > h5 + h6 { + margin-block-start: calc(var(--space_flow) / 2); +} + +.section { + margin-block-start: var(--space_section); +} + +/* !SECTION Flow Spacing */ +/* ============================== +/* SECTION Level +** Creates a bar with content vertically aligned on both sides +** Good for split navigation bars etc. +============================== */ + +.layout--level { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-content: center; + gap: var(--space_gutter); +} + +/* !SECTION Level */ +/* ============================== +/* SECTION Grid +** The grid layout creates a responsive grid with a fluid number of columns. +** You can define the number of columns to allow as well as the minimum +** width of each grid item. Just be aware that if you set the minimum width +** too high, the grid may not fit on viewports smaller than the minimum width. +============================== */ + +.layout--grid { + --grid-column--count: 6; + --grid-gap--width: var(--space_grid-gap); + --grid-gap--count: calc(var(--grid-column--count) - 1); + --grid-gap--total: calc(var(--grid-gap--width) * var(--grid-gap--count)); + --grid-item--min-width: 14rem; + --grid-item--max-width: calc((100% - var(--grid-gap--total)) / var(--grid-column--count)); + + display: grid; + grid-template-columns: repeat(auto-fit, minmax(max(var(--grid-item--min-width), var(--grid-item--max-width)), 1fr)); + gap: var(--grid-gap--width); +} + +/* Stabilize height of components that are side by side */ + +.layout--grid > div > :only-child, +.layout--grid > li > :only-child { + display: flex; + align-items: stretch; + height: 100%; +} + +.layout--grid > div > :only-child > *, +.layout--grid > li > :only-child > * { + flex: 1; +} + +/* !SECTION Grid */ +/* ============================== +/* SECTION App +** The app wrapper ensures the the site footer sticks to the +** bottom of the viewport on short pages. +** Structure should be as follows: +**
+**
+**
+**
+**
+** No other elements should be present in the app div. +============================== */ + +#app { + min-height: 100vh; + display: flex; + flex-direction: column; + max-width: 100vw; +} + +#app > :nth-child(2) { + width: 100%; + flex: 1 0 auto; + place-content: start center; +} + +/* !SECTION App */ +/* ============================== +/* SECTION Page Layout +** The page layout provides grid areas for a hero, content, and sidebar. +** It arranges the grid areas responsively based on the presence of a sidebar. +** Inside the
tag, the structure should follow: +**