From 5ae972ba2af2e9ab27bd593bc5df06a44df34757 Mon Sep 17 00:00:00 2001 From: Chris Najman Date: Thu, 9 Nov 2023 07:48:04 +0000 Subject: [PATCH] First commit --- .gitignore | 0 LICENSE | 21 +++ README.md | 126 +++++++++++++++ css/base.css | 195 +++++++++++++++++++++++ css/form.css | 65 ++++++++ css/header.css | 29 ++++ css/modal.css | 19 +++ css/style.css | 7 + css/table.css | 183 ++++++++++++++++++++++ css/theme-picker.css | 41 +++++ css/theme.css | 127 +++++++++++++++ favicon.ico | Bin 0 -> 15406 bytes img/close.svg | 1 + img/dictionary.png | Bin 0 -> 880 bytes img/sort-a-z.svg | 1 + img/sort-z-a.svg | 1 + index.html | 156 ++++++++++++++++++ js/script.js | 366 +++++++++++++++++++++++++++++++++++++++++++ 18 files changed, 1338 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 css/base.css create mode 100644 css/form.css create mode 100644 css/header.css create mode 100644 css/modal.css create mode 100644 css/style.css create mode 100644 css/table.css create mode 100644 css/theme-picker.css create mode 100644 css/theme.css create mode 100644 favicon.ico create mode 100644 img/close.svg create mode 100644 img/dictionary.png create mode 100644 img/sort-a-z.svg create mode 100644 img/sort-z-a.svg create mode 100644 index.html create mode 100644 js/script.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b657ae8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Christopher. Najman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf097b --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Sortable Dictionary (V2) + +## Description + +I'm always looking up words, in several languages, so I decided to build my own sortable dictionary. + +Note: the page doesn't break when viewed on a mobile, but I'd recommend using tablet or desktop for the best experience. + +This build features enhancements and modifications to my previous [sortable dictionary](https://github.com/chrisnajman/sortable-dictionary) project. + +### How it works + +#### Adding an entry + +- Add a word or phrase (required) to the modal dialog form. + - All language characters and accents are allowed, together with hyphens and single apostrophes. + - Special characters are not allowed. + - Clicking 'Cancel' will close the form and modal. +- Select a language (optional): + - If the form is submitted without selecting a language, the default language (English) will be printed to the table row. + - If the language is not available, you can select "Other". This can be edited after the form is submitted. +- Add a definition (optional). This can be added (or edited) after the form is submitted. + +#### Editing/sorting/deleting an entry + +Once the form has been submitted, a new table row is generated. + +Note: usually, new table rows go to the bottom of the table. For ease of use in this project, the newly-generated entry appears at the top. To signal this, the new entry is briefly highlighted. + +- Either the word/phrase or language can be sorted (a-z and z-a) via: + - two buttons in the 'Word/phrase' table header, and + - two buttons in the 'Language' table header. + - At least two rows need to be generated before the sort buttons are enabled. +- A definition can be edited (or added). +- If the language selected was "Other", a "Set language" button will appear in the entry under 'Language'. + +Entries, edits, additions and sort order are saved to local storage. +Deleted entries are also deleted from local storage. + +## HTML + +- `template` used for dynamic `table` rows. +- `dialog` is used to house the form. +- The table has a min-width of 736px. At screens smaller than 768px (width dimensions of iPad Mini) scrollbars will appear. + +## Javascript + +- Dialog modal. +- Theme switcher. +- Sort functionality (using ES6 `.sort()` method). +- ES6 (no transpilation to ES5) + +## CSS + +- `flexbox` used for page layout and page elements. +- Files are organised using `@import` to pull modules into `style.css`. +- Files are organised internally using CSS nesting. +- Responsive (as far as a data table can be responsive). +- Dark theme. + +## Adding a language + +This requires adding code to both the HTML and JavaScript (You can add as many languages as you require). +First, look up the relevant [ISO Country Code](https://www.w3docs.com/learn-html/html-language-codes.html). + +- E.g. for 'Portuguese', the country code is 'pt'. + +Then, in `./index.html` add `` somewhere in the `select` dropdown: + +```HTML + +``` + +Finally, in `./js/script.js` add a new `case` in the `switch` statement: + +```JavaScript +function convertLangSelectToFullName(entry) { + switch (entry.language) { + case "en": + entry.language = "English" + break + case "fr": + entry.language = "French" + break + case "de": + entry.language = "German" + break + case "it": + entry.language = "Italian" + break + case "la": + entry.language = "Latin" + break + case "es": + entry.language = "Spanish" + break + case "pt": + entry.language = "Portuguese" /* Portuguese added */ + break + case "und": + entry.language = "Other" + break + default: + entry.language = "Not available" + } +} +``` + +## Testing + +- Tested on: + - Windows 10 + - Chrome + - Firefox + - Microsoft Edge + +The page has been tested in both browser and device views. diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..3026e78 --- /dev/null +++ b/css/base.css @@ -0,0 +1,195 @@ +*, +*::after { + box-sizing: border-box; + margin: 0; +} + +html { + overflow-y: scroll; + scroll-behavior: smooth; + + /* + This allows easily setting any/all dimension in rems, + e.g. width: 30rem === width: 300px + */ + font-size: 10px; +} + +html:focus-within { + scroll-behavior: smooth; +} + +body { + background-color: var(--body-bg); + font-family: var(--font-sans); + margin: 0; + line-height: 1.5; + font-size: 1.6rem; +} + +h1 { + font-size: clamp(1.6rem, 1.1739rem + 2.4348vw, 3rem); + line-height: 1.1; + text-wrap: balance; +} + +img { + max-width: 100%; + height: auto; + vertical-align: middle; /* replaces display: block but also removes space below */ + font-style: italic; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; +} + +button { + all: unset; + color: inherit; + cursor: pointer; +} + +button, +input[type="text"], +textarea, +select { + font-size: inherit; + font-family: inherit; +} + +.button { + color: var(--class-button-text); + background-color: var(--class-button-bg); + padding: 0.4rem 0.8rem; + border: 1px solid var(--class-button-border); + border-radius: 0.3rem; +} +.button:hover { + opacity: 0.7; +} + +.page-layout { + min-height: 100vh; + min-height: 100dvh; + display: var(--flex); + flex-direction: column; + align-items: stretch; +} + +header, +main, +footer { + flex-shrink: 0; +} + +main { + flex-grow: 1; +} + +.page-header { + padding: 1.6rem 1.6rem 3.2rem 1.6rem; +} + +.main, +.page-footer { + padding: 5rem 1.6rem; +} + +.page-header, +.page-footer { + background-color: var(--header-footer-bg); + color: var(--text-lightest); + text-align: center; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.page-footer a { + color: var(--link); + text-decoration: none; +} + +.page-footer a:hover { + text-decoration: underline; +} + +.page-footer a::after { + content: " \27F6"; +} + +/** Helpers */ +/* Scrollable container for tables */ +[role="region"][aria-labelledby][tabindex] { + overflow: auto; +} + +/* Skip link */ +.skip-link { + background-color: var(--skip-link-bg); + color: var(--skip-link-text); + text-decoration: none; + padding: 1rem 1.5rem; + border-radius: 0 0 0.8rem 0; +} +.element-invisible { + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + overflow: hidden; + position: absolute; + left: 0; + z-index: 10; +} +.element-invisible.element-focusable:active, +.element-invisible.element-focusable:focus { + clip: auto; + height: auto; + overflow: visible; +} +/* Screenreader only */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(0); + border: 0; +} + +/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */ +@media (prefers-reduced-motion: reduce) { + html, + html:focus-within { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + transition-delay: 0ms !important; + } +} + +.info { + font-size: 1.4rem; + margin-bottom: 0.5rem; +} + +.warning { + color: var(--clr-warning); +} + +.serif { + font-family: var(--font-serif); +} +/* Always comes last **/ +.hide { + display: none; +} diff --git a/css/form.css b/css/form.css new file mode 100644 index 0000000..884f855 --- /dev/null +++ b/css/form.css @@ -0,0 +1,65 @@ +.entries-form { + display: var(--flex); + flex-direction: column; + gap: 3rem; + padding: 3rem; + text-align: left; + background-color: var(--form-bg); + color: var(--form-text); + + & > div:not(.button-group) { + display: var(--flex); + flex-direction: column; + gap: 0.5rem; + text-align: left; + } + + & .button-group { + display: var(--flex); + gap: 1rem; + padding-block-start: 0.5rem; + + & .button { + border: 1px solid var(--form-border); + background-color: var(--form-button-bg); + color: var(--form-button-text); + + &:first-child { + margin-inline-start: auto; + } + } + } + + & input[type="text"], + & select { + padding: 0.3rem; + } + + & input[type="text"], + & textarea, + & select { + background-color: var(--form-field-bg); + color: var(--form-text); + border-color: var(--form-border); + border-width: 0.1rem; + border-radius: 0.2rem; + } + + & input[type="text"], + & textarea { + font-family: var(--font-serif); + } + & textarea { + width: 100%; + padding: 0.5rem 1rem; + } + + @media screen and (width <= 390px) { + & .button-group { + flex-direction: column; + } + & button { + margin-inline-start: auto; + } + } +} diff --git a/css/header.css b/css/header.css new file mode 100644 index 0000000..504b309 --- /dev/null +++ b/css/header.css @@ -0,0 +1,29 @@ +.page-header { + & img { + margin: 0 auto; + } + & h1 { + max-width: 48rem; + margin: var(--horz-center); + font-weight: 400; + + & .plus { + position: relative; + top: 0.2rem; + } + + & button { + display: var(--flex); + align-items: center; + gap: 1rem; + padding: 0.8rem 1.6rem 0.8rem 2.5rem; + border-radius: 3.2rem; + background-color: var(--header-button-bg); + color: var(--header-button-text); + + &:hover { + opacity: 0.8; + } + } + } +} diff --git a/css/modal.css b/css/modal.css new file mode 100644 index 0000000..38f8e07 --- /dev/null +++ b/css/modal.css @@ -0,0 +1,19 @@ +.modal { + z-index: 50; + padding: 0; + margin: auto; + border-radius: 0.5rem; + border: 0; + background-color: var(--clr-none); + box-shadow: -1px 4px 10px 0px rgba(0, 0, 0, 0.7); + + &::backdrop { + background-color: rgba(0, 0, 0, 0.7); + background-image: url("../img/close.svg"); + background-repeat: no-repeat; + background-position-x: 99%; + background-position-y: 1rem; + z-index: 40; + cursor: pointer; + } +} diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..24fb52e --- /dev/null +++ b/css/style.css @@ -0,0 +1,7 @@ +@import url("./theme.css"); +@import url("./base.css"); +@import url("./theme-picker.css"); +@import url("./header.css"); +@import url("./modal.css"); +@import url("./form.css"); +@import url("./table.css"); diff --git a/css/table.css b/css/table.css new file mode 100644 index 0000000..98248a7 --- /dev/null +++ b/css/table.css @@ -0,0 +1,183 @@ +.table { + border: 0; + border-spacing: 0; + border-collapse: collapse; + margin: var(--horz-center); + width: 100%; + min-width: 73.6rem; /* Fits Ipad Mini: At viewport width 768px, no scrollbars on table container */ + max-width: 120rem; + font-size: clamp(1.6rem, 1.4512rem + 0.3101vw, 1.8rem); +} + +.thead { + & tr { + background-color: var(--thead-tr); + color: var(--th-text); + } + + & th { + border-width: 0 2px 2px 0; + border-style: solid; + border-color: var(--th-border-right); + border-bottom: (--th-border-bottom); + padding: 1.6rem 1rem; + + &:last-child { + border-right: 0; + } + + & > div { + display: flex; + flex-wrap: wrap; + gap: 1.6rem; + align-items: center; + justify-content: center; + + @media screen and (width <= 1040px) { + flex-direction: column; + } + } + } + + & .sort-buttons { + display: var(--flex); + gap: 1rem; + justify-content: center; + padding-block-start: 0.5rem; + + & button { + padding: 0.5rem; + outline: 1px solid var(--clr-grey-light); + &:hover { + opacity: 0.8; + } + + &[disabled] { + background-color: grey; + cursor: default; + opacity: 0.5; + + &:hover { + opacity: 0.5; + } + } + } + + & img { + min-width: 2.4rem; + } + } + + & .heading-word { + width: 30%; + } + & .heading-definition { + width: 40%; + } + & .heading-language { + width: 20%; + } + & .heading-delete { + width: 10%; + + & img { + width: 40%; + margin: var(--horz-center); + padding: 0; + display: block; + } + } + + /* Hack: stops overflow showing in device view */ + & .visually-hidden { + top: -10000px; + left: -10000px; + } +} + +.tbody { + & tr { + border-bottom: 2px solid var(--tr-border-bottom); + &:last-child { + border-bottom: 0; + } + } + & tr:nth-child(odd) { + background-color: var(--tr-odd-bg); + } + & tr:nth-child(even) { + background-color: var(--tr-even-bg); + } + & tr:nth-child(odd) td { + border-right-color: var(--tr-odd-td-border-right); + } + & tr:nth-child(even) td { + border-right-color: var(--tr-even-td-border-right); + } + + & td { + color: var(--td-text); + border-right-width: 2px; + border-right-style: solid; + vertical-align: top; + padding: 0.5rem 0; + + &:last-child { + border-right: 0; + } + + & > * { + display: var(--flex); + flex-wrap: wrap; + gap: 3rem; + padding: 1.6rem; + align-items: center; + justify-content: center; + + &:has(p) { + justify-content: flex-start; + } + } + } + + & .button { + padding-block-start: 0; + padding-block-end: 0; + } + + & .word-cell p, + & .language-cell p { + justify-content: flex-start; + } + + & .language-cell button, + & .definition-cell button { + margin-left: auto; + } + + & p { + max-width: 40ch; + overflow-x: auto; + + &[contenteditable] { + background-color: var(--content-editable-bg); + outline: 1px dotted var(--content-editable-border); + color: var(--content-editable-text); + padding: 0.25rem; + cursor: default; + width: 100%; + } + } + + & tr.latest-entry { + animation: highlight 1.5s ease-in-out; + } +} +@keyframes highlight { + 0% { + background-color: var(--tr-highlight-newest); + } + 100% { + background-color: var(--tr-odd-bg); + } +} diff --git a/css/theme-picker.css b/css/theme-picker.css new file mode 100644 index 0000000..212baaf --- /dev/null +++ b/css/theme-picker.css @@ -0,0 +1,41 @@ +/** Dark mode button */ +.theme-picker { + position: absolute; + top: 0; + right: 0; + z-index: 50; + width: fit-content; +} + +.theme-icon { + width: 2.4rem; + height: 2.4rem; + fill: var(--theme-icon); + margin-right: 0.5rem; +} + +.btn-theme-toggle { + display: var(--flex); + align-items: center; + padding: 0.5rem 1rem; + margin: 0 0 0 0.75rem; + border-radius: 0 0 0 0.8rem; + background-color: var(--theme-icon-bg); + color: var(--theme-text); + font-size: clamp(1.2rem, 1.1rem + 0.4444vw, 1.6rem); + + &:hover { + opacity: 0.8; + } +} + +[aria-pressed] .icon-moon { + fill: var(--theme-icon); +} + +[aria-pressed="false"] .btn-theme-state::after { + content: ": off"; +} +[aria-pressed="true"] .btn-theme-state::after { + content: ": on"; +} diff --git a/css/theme.css b/css/theme.css new file mode 100644 index 0000000..8c4d1fa --- /dev/null +++ b/css/theme.css @@ -0,0 +1,127 @@ +:root { + --font-sans: sans-serif; + --font-serif: Georgia, serif; + --horz-center: 0 auto; + --flex: flex; + + /** Colours **/ + --clr-none: transparent; + --clr-lightest: white; + --clr-pale-grey: rgb(245, 245, 250); + --clr-pale-blue: rgb(187, 229, 245); + --clr-blue: rgb(83, 104, 223); + --clr-darkest-blue: rgb(9, 9, 70); + --clr-grey-light: rgb(226, 225, 225); + --clr-grey-neutral-dark: rgb(48, 48, 48); /* thead tr */ + --clr-grey-neutral-darkest: rgb(33, 33, 33); /* body table all rows */ + --clr-black: rgb(0, 0, 0); + --clr-warning: red; + + /** Named vars **/ + /* Constants */ + --focus: var(--clr-pale-blue); + --bg-darkest: var(--clr-darkest-blue); + --text-lightest: var(--clr-lightest); + --theme-icon: var(--clr-grey-light); + + /** Light theme */ + --link: var(--clr-lightest); + --header-footer-bg: var(--clr-darkest-blue); + --body-bg: var(--clr-pale-grey); + --text: var(--clr-darkest-blue); + + /** Skip Link **/ + --skip-link-bg: var(--clr-blue); + --skip-link-text: var(--clr-lightest); + + /** .button */ + --class-button-bg: var(--clr-lightest); + --class-button-border: var(--clr-darkest-blue); + --class-button-text: var(--clr-darkest-blue); + + /* form*/ + --form-bg: var(--clr-pale-grey); + --form-text: var(--clr-darkest-blue); + --form-field-bg: var(--clr-lightest); + --form-button-bg: var(--clr-blue); + --form-button-text: var(--clr-lightest); + --form-border: var(--clr-darkest-blue); + + /* theme button */ + --theme-icon-bg: var(--clr-blue); + --theme-text: var(--clr-lightest); + + /** Page header */ + --header-button-bg: var(--clr-lightest); + --header-button-text: var(--clr-darkest-blue); + + /** table */ + /* thead */ + --thead-tr: var(--bg-darkest); + --th-border-right: var(--clr-lightest); + --th-border-bottom: var(--clr-none); + --th-text: var(--clr-lightest); + + /* tbody */ + --tr-highlight-newest: var(--clr-pale-grey); + --tr-odd-bg: var(--clr-lightest); + --tr-even-bg: var(--clr-pale-grey); + --tr-odd-td-border-right: var(--clr-pale-grey); + --tr-even-td-border-right: var(--clr-lightest); + --tr-border-bottom: var(--clr-none); + --td-text: var(--clr-darkest-blue); + --content-editable-bg: var(--clr-darkest-blue); + --content-editable-text: var(--clr-lightest); + --content-editable-border: var(--clr-blue); +} + +.dark-theme { + --link: var(--clr-pale-blue); + --header-footer-bg: var(--clr-grey-neutral-darkest); + --body-bg: var(--clr-black); + --text: var(--clr-grey-light); + + /** Skip Link **/ + --skip-link-bg: var(--clr-black); + --skip-link-text: var(--clr-pale-blue); + + /* form */ + --form-bg: var(--clr-grey-neutral-dark); + --form-text: var(--clr-grey-light); + --form-field-bg: var(--clr-grey-neutral-darkest); + --form-button-bg: var(--clr-grey-neutral-darkest); + --form-button-text: var(--clr-pale-blue); + --form-border: var(--clr-black); + + /* theme button */ + --theme-icon-bg: var(--clr-black); + --theme-text: var(--clr-pale-blue); + + /** .button */ + --class-button-bg: var(--clr-none); + --class-button-border: var(--clr-none); + --class-button-text: var(--clr-pale-blue); + + /** Page header */ + --header-button-bg: var(--clr-black); + --header-button-text: var(--clr-pale-blue); + + /** table */ + --table-text: ; + /* thead */ + --thead-tr: var(--clr-grey-neutral-dark); + --th-border-right: var(--clr-black); + --th-border-bottom: var(--clr-black); + --th-text: var(--clr-grey-light); + /* tbody */ + --tr-highlight-newest: var(--clr-grey-neutral-dark); + --tr-odd-bg: var(--clr-grey-neutral-darkest); + --tr-even-bg: var(--clr-grey-neutral-darkest); + --tr-odd-td-border-right: var(--clr-black); + --tr-even-td-border-right: var(--clr-black); + --tr-border-bottom: var(--clr-black); + --td-text: var(--clr-grey-light); + --content-editable-bg: var(--clr-black); + --content-editable-text: var(--clr-grey-light); + --content-editable-border: var(--clr-pale-blue); +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1c1e174da60b802356689df00bb6a92dc1fb7e5d GIT binary patch literal 15406 zcmeI3NsAOo6vuOz`2fyg?tQUsH*6JK#e;nj1aFEfE{(Xff=59=fgmV)a6xcDym;}T z2XVQG;Goco$S5MrD2ll6xNFAbFZ7>8WoJZXW@S}3i~(Up#q!?&E%72UqK%?oqFdw=wIN6}w@L{U#NCYh4V_}-orxyd*wj$`xn z>(^lZ_U)TtTsECKbH>b?HOq8&cQ?(87AFF_=PwCEg>8O7C^l5YM+&MFG;>1)o=78Is&v?;?2X)%Uj~{Q)51Be`nx~RB zvWu0v+Tjfbu=wU9|I?>W&73)NY<~9aS)7cG!Qs-C{NhasP3D7f>(;F{pFMjvPTHW6 z;!xe4>&{=hcCGpR`LoT`eKzsUXS{gi%~ikV%Lc-)xuH`22Mv<&U%h&Dm|et2CF^6` zwrzHt{7?Q{Wd5sHugth{;~M5eN9zaMgfep%E?n5Keq0>x`o-q%Z|cYaUZvciP@<7I z)0O1@Bom)}N&I}J>?l2#DSb4Hw8Iy@xa*^pI=G9b+qZ9<4I4I?-rnAz%|rf>?VCM& zwv7?ZFu;4-v}w8P)3@%5wu2$02Xv0yMd_6thfc7eFJW(VUY0K7v--~d)|e1~`j}Ui zKXX2Q{OI*n`h=%l{6!c0Qg?HRKQnHr{}6_<{Ml2{y?BW?C2jOE2A|9;Cq!5K?FUnv`vm0xGVhYuf`Ns}hoOdb6ted3>DgO4v)ksrUgdGn^_ zCw;=3Hu`+L^4hELhtB5Bo6Y<8@6+=;b=vZHgxad|hbHSxw$+*o@yhFKg?}EsvTY;H z-#ve6{oBR$;~)GBjFL++OL}jm%$`5tPj-{bf77N-HulNqTPE{E z{>V+-tt&y7?|aw&d9-!@YzKeY4lKTeJ#_xAQ~yI(t_<7%d>GvCkGlL1I`}ka6YYsU z{smnvIsxly}FSzWhcw~(Eo+ld69ybQLycG`s7*RS?=;fFr!jC)niyq(I#0eDwU=j=HQ2$bIeFH8U_UQ2IQqb50#4xxQj8s_`KXIn&ePu|-2@@umGFh~$ z;D?Xq*HMDu?c29D2BV*5nX$q!TJV?gVdNXl_}N=R`(RQ16Kj;UzFpbt@EvGyxA6Ck z8)+PW{`|RFw{D%i_rX8%vUrF5$32_06F*qk!?}Z&zNoKWy}Ic-%i`C3%Ch*~_#ypd zOWzp2*Oo0?X71g)XH^A%-mllJSre2Y55I7$gqD)ef1sln;`*nHzsQ)SOP5+1#1k2? zgYW*TPW*}?AsL&EL0;@o)R)?YpFPg?73x!_OiA~8pN!xy<4f(pkL|&im*p2B{PMXX zzEmgv4<9~gnKUi&BWxn9zB133R;^l<&Z`su>({R>ZglM1f2996Z{FCrF5FpP({J9r z!296AgOv6^|Nfianbw6LpS^SEj^)7_b-{uK!}gu|^XC(j31;#A`}gA{rr5WL1VgOY zmFIJ`5et=E+tr01J)S>*-m*Z)d2a>l*RQAF;=g$DqTW$r@GC9}W2ki3PM!Go?%kWR zJ#yqo>Z9_Jg9i`NZ}A^Gbf^J8{^awyY`eR^)QNxP%9ZwR5Rk<0$&)9iGEg5H8nXD4 z`@?wS#*HcdL_TaV%-=$7(n+28c|XeNw(AH8kgzTLcg_b!z|@dSAkR~b`A$Dy%x z>K{J+{r&cx)H+JeKX>omwPQk<%JkKZAKrt5gZ3?o{n*XP+rQbeWlL?khgUoBLznX| zwB77G1DDZvc5J)wXZf`2_fP!(zny58&Rx2^54!iC#5ni6k>6w`_w3=)8HqZ+lf8d- z@e8wC`tb8stTS&X%D%onyC<^GyGi}b;oW1NG z3Vz \ No newline at end of file diff --git a/img/dictionary.png b/img/dictionary.png new file mode 100644 index 0000000000000000000000000000000000000000..3c4800b210de5d99bcba661249d194eabc613e33 GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!7>k44ofvPP)Tsw@I14-?iy0WW zg+Z8+Vb&Z8paSjypAc7|C=1m>^XGk(8Y@vmo8m;>eQ+A>(_gDc(k>(0U2AjZUw5`v}qHNeeK#c zpg7PdAlJge0%!$L7m$7F(xo$J&a7R#7N`J79z1xkqoYGbMMYFpbne`_a&mG?N=m)G zy+AE9X3UV4l?B=;B_*|P-8z4N|J>Z%fPjFUoE!xO1)vqPX3bJlQ(L)mrM$fS_3PJn z?b=mbTShHqL=_yWeU~oj11o;Is=q>v2|NsAers6yP|2N^kxaIhNi;Es& z^1Eif=Fk4G_>fCHe67d-x_%dMF&-8S045^s&cKXe*Lk1$Q z-4Y?MY!+XubXnEZ)x;de>Ut#d%m4jHW`|ArwoKK$vdF?e{qEGg)))1XB>sO?y(aiR zMaZ+HwEM%nC7RB=U%owD^S{A_Px45UBG>sgLHk3;7bib{`~J!imH4h>ni77#+mG*b z)aW=|;MQf%v+zLE!XrQ1xqlRdg)b=Otg$fHSC5E@+0??c^Z1LjNSz61PQ8)}n^f1H zQ|laj;LEy<7hl>UtP1s;mYje>kUF3U;kXq!DJID@MNWY=n-Mo8Ou524E%*HfV7AH zWqvV_T#=|Imi4=urmRy?+aD{)-J{Z(pTL-Vy7~XJi)OL;@0l9^Y+FB3>~p=T{Oo59 VVF9xnHGwIC!PC{xWt~$(69CZAu}uH~ literal 0 HcmV?d00001 diff --git a/img/sort-a-z.svg b/img/sort-a-z.svg new file mode 100644 index 0000000..79eaff7 --- /dev/null +++ b/img/sort-a-z.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/img/sort-z-a.svg b/img/sort-z-a.svg new file mode 100644 index 0000000..25f2a8f --- /dev/null +++ b/img/sort-z-a.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..f23d8ca --- /dev/null +++ b/index.html @@ -0,0 +1,156 @@ + + + + + + + + + + Sortable Dictionary V.2 + + + + + + +
+ +
+
+ +
+
+ + + + + + + + + + + + +
Sortable Dictionary
+
+ Word / phrase +
+ + +
+
+
+
Definition
+
+
+ Language +
+ + +
+
+
+
+ Delete row + Delete row +
+
+
+
+ +
+ + + + \ No newline at end of file diff --git a/js/script.js b/js/script.js new file mode 100644 index 0000000..479acaa --- /dev/null +++ b/js/script.js @@ -0,0 +1,366 @@ +const LOCAL_STORAGE_PREFIX = "DICTIONARY" +const ENTRIES_STORAGE_KEY = `${LOCAL_STORAGE_PREFIX}-entries` +const SORT_BUTTONS_STORAGE_KEY = `${LOCAL_STORAGE_PREFIX}-sort-buttons-enabled` +const DARKMODE_STORAGE_KEY = `${LOCAL_STORAGE_PREFIX}-darkmode-switch` + +let entries = JSON.parse(localStorage.getItem(ENTRIES_STORAGE_KEY)) || [] + +// Theme +const btnThemeToggle = document.getElementById("btn-theme-toggle") +const themeSun = document.getElementById("theme-sun") +const themeMoon = document.getElementById("theme-moon") +const root = document.querySelector("html") + +// Modal +const launchModalBtn = document.getElementById("launch-modal") +const modal = document.getElementById("modal") + +// Form +const entriesForm = document.getElementById("entries-form") +const btnCancel = document.getElementById("cancel") + +// Sort +const btnSortWordsAZ = document.getElementById("btn-sort-words-a-z") +const btnSortWordsZA = document.getElementById("btn-sort-words-z-a") +const btnSortLanguagesAZ = document.getElementById("btn-sort-languages-a-z") +const btnSortLanguagesZA = document.getElementById("btn-sort-languages-z-a") +const sortButtons = document.querySelectorAll(".sort-button") + +setTheme() + +entriesForm.addEventListener("submit", (e) => { + e.preventDefault() + + const term = document.getElementById("term").value + const definition = document.getElementById("definition").value + const lang = document.querySelector("[data-language-select]").value + const language = document.getElementById("language-select").value + const entry = { + term, + definition, + lang, + language, + id: new Date().valueOf().toString(), + } + + convertLangSelectToFullName(entry) + entries.push(entry) + updateEntriesInLocalStorage() + hightlightFirstRow() + resetFormCloseModal() + checkAndUpdateSortButtons() +}) + +btnCancel.addEventListener("click", resetFormCloseModal) + +displayTableRows(entries) + +/** Sort Buttons */ +btnSortWordsAZ.addEventListener("click", sortTermsAZ) +btnSortWordsZA.addEventListener("click", sortTermsZA) +btnSortLanguagesAZ.addEventListener("click", sortLangsAZ) +btnSortLanguagesZA.addEventListener("click", sortLangsZA) + +// Table / Delete button +addGlobalEventListener("click", "[data-delete-row]", (e) => { + // Remove from the screen + const parent = e.target.closest(".table-row") + parent.remove() + + // Remove from local storage + const entryId = parent.dataset.entryId + + entries = entries.filter((entry) => { + return entry.id !== entryId + }) + + updateEntriesInLocalStorage() + checkAndUpdateSortButtons() +}) + +// Table / Edit word button +addGlobalEventListener("click", "[data-edit-word]", (e) => { + e.target.textContent = e.target.textContent === "Edit" ? "Save edit" : "Edit" + const parent = e.target.closest(".table-row") + + const text = parent.querySelector("[data-def]") + text.toggleAttribute("contenteditable") + + const entryId = parent.dataset.entryId + const entry = entries.find((ent) => { + return ent.id === entryId + }) + + entry.edited = !text.hasAttribute("contenteditable") + if (entry.edited) entry.definition = text.textContent + + localStorage.setItem(ENTRIES_STORAGE_KEY, JSON.stringify(entries)) +}) + +// Table / Set language when 'Other' is chosen from language dropdown +addGlobalEventListener("click", "[data-edit-language]", (e) => { + e.target.textContent = + e.target.textContent === "Set language" ? "Save" : "Set language" + const parent = e.target.closest(".table-row") + + const text = parent.querySelector("[data-lang]") + text.toggleAttribute("contenteditable") + + const entryId = parent.dataset.entryId + const entry = entries.find((ent) => { + return ent.id === entryId + }) + + entry.edited = !text.hasAttribute("contenteditable") + if (entry.edited) { + entry.language = text.textContent + e.target.remove() + } + + localStorage.setItem(ENTRIES_STORAGE_KEY, JSON.stringify(entries)) +}) + +/** Modal */ +launchModalBtn.addEventListener("click", () => { + modal.showModal() +}) + +/* modal backdrop (anything that's not the form) */ +modal.addEventListener("click", (e) => { + const formClicked = e.target.closest("form") + if (!formClicked) { + resetFormCloseModal() + } +}) + +/** Theme */ +btnThemeToggle.addEventListener("click", (e) => { + root.classList.toggle("dark-theme") + + const isDarkMode = root.classList.contains("dark-theme") + + e.target.setAttribute("aria-pressed", String(isDarkMode)) + + if (isDarkMode) { + themeSun.classList.add("hide") + themeMoon.classList.remove("hide") + } else { + themeMoon.classList.add("hide") + themeSun.classList.remove("hide") + } + + const sunClass = themeSun.classList.contains("hide") ? "hide" : "" + const moonClass = themeMoon.classList.contains("hide") ? "hide" : "" + + const themeItems = [isDarkMode, sunClass, moonClass] + localStorage.setItem(DARKMODE_STORAGE_KEY, JSON.stringify(themeItems)) +}) + +/** Functions */ +function resetFormCloseModal() { + entriesForm.reset() + modal.close() +} + +function updateEntriesInLocalStorage() { + setEntries(entries) + displayTablerowsAndSetItem() + + if (entries.length >= 2) { + localStorage.setItem(SORT_BUTTONS_STORAGE_KEY, "true") + } else { + localStorage.setItem(SORT_BUTTONS_STORAGE_KEY, "false") + } +} + +function setEntries(newEntries) { + entries = newEntries +} + +/* + Because last entry displays at the top of the table, + A-Z/Z-A sort order has to be reversed. +*/ +function sortTermsAZ() { + entries.sort((a, b) => { + const termA = a.term.toLowerCase() + const termB = b.term.toLowerCase() + return termB.localeCompare(termA) + }) + displayTablerowsAndSetItem() +} + +function sortTermsZA() { + entries.sort((a, b) => { + const termA = a.term.toLowerCase() + const termB = b.term.toLowerCase() + return termA.localeCompare(termB) + }) + displayTablerowsAndSetItem() +} + +function sortLangsAZ() { + entries.sort((a, b) => { + const languageA = a.language.toLowerCase() + const languageB = b.language.toLowerCase() + return languageB.localeCompare(languageA) + }) + displayTablerowsAndSetItem() +} + +function sortLangsZA() { + entries.sort((a, b) => { + const languageA = a.language.toLowerCase() + const languageB = b.language.toLowerCase() + return languageA.localeCompare(languageB) + }) + displayTablerowsAndSetItem() +} + +function displayTablerowsAndSetItem() { + displayTableRows(entries) + localStorage.setItem(ENTRIES_STORAGE_KEY, JSON.stringify(entries)) + if (entries.length >= 2) { + enableSortButton() + localStorage.setItem(SORT_BUTTONS_STORAGE_KEY, "true") + } else { + disableSortButton() + localStorage.setItem(SORT_BUTTONS_STORAGE_KEY, "false") + } +} + +// This displays last entry at the top of the table. Because of this, A-Z/Z-A sort order has to be reversed. +function displayTableRows(entries = []) { + const tableBody = document.getElementById("table-body") + tableBody.innerHTML = "" // Clears the table body + const template = document.getElementById("table-row-template") + + entries.forEach((entry) => { + const newRow = template.content.cloneNode(true) + newRow.querySelector(".table-row").dataset.entryId = entry.id + newRow.querySelector("[data-term]").textContent = entry.term + newRow.querySelector("[data-term]").setAttribute("lang", entry.lang) + newRow.querySelector("[data-def]").textContent = entry.definition + newRow.querySelector("[data-lang]").textContent = entry.language + + const langer = newRow.querySelector("[data-lang]") + + if (langer.textContent === "Other") { + const languageButton = document.createElement("button") + languageButton.setAttribute("data-edit-language", "") + languageButton.classList.add("button", "btn-edit-language") + languageButton.textContent = "Set language" + langer.after(languageButton) + } + + // Append new row at the BEGINNING of the table body + tableBody.insertBefore(newRow, tableBody.firstChild) + }) + + if (entries.length >= 2) { + enableSortButton() + } +} + +function hightlightFirstRow() { + if (document.querySelector(".table-row:first-child")) { + document + .querySelector(".table-row:first-child") + .classList.add("latest-entry") + } +} + +function addGlobalEventListener(type, selector, callback, option = false) { + document.addEventListener( + type, + (e) => { + if (e.target.matches(selector)) callback(e) + }, + option + ) +} + +function convertLangSelectToFullName(entry) { + switch (entry.language) { + case "en": + entry.language = "English" + break + case "fr": + entry.language = "French" + break + case "de": + entry.language = "German" + break + case "it": + entry.language = "Italian" + break + case "la": + entry.language = "Latin" + break + case "es": + entry.language = "Spanish" + break + case "und": + entry.language = "Other" + break + default: + entry.language = "Not available" + } +} + +function checkAndUpdateSortButtons() { + const numTableRows = document.querySelectorAll(".table-row").length + if (numTableRows < 2) { + sortButtons.forEach((button) => { + button.setAttribute("disabled", "") + }) + } else { + sortButtons.forEach((button) => { + button.removeAttribute("disabled") + }) + } +} + +function enableSortButton() { + sortButtons.forEach((button) => { + button.removeAttribute("disabled") + }) + + localStorage.setItem(SORT_BUTTONS_STORAGE_KEY, "true") +} + +function disableSortButton() { + sortButtons.forEach((button) => { + button.setAttribute("disabled", "true") + }) + + localStorage.setItem(SORT_BUTTONS_STORAGE_KEY, "false") +} + +function setTheme() { + const activeTheme = JSON.parse( + localStorage.getItem(DARKMODE_STORAGE_KEY) + ) || [false, "", ""] + + const isDarkMode = activeTheme[0] + + if (isDarkMode) { + root.classList.add("dark-theme") + themeSun.classList.add("hide") + themeMoon.classList.remove("hide") + } else { + root.classList.remove("dark-theme") + themeMoon.classList.add("hide") + themeSun.classList.remove("hide") + } + + btnThemeToggle.setAttribute("aria-pressed", String(isDarkMode)) +} + +/* On page load */ +const areSortButtonsEnabled = localStorage.getItem(SORT_BUTTONS_STORAGE_KEY) +if (areSortButtonsEnabled && areSortButtonsEnabled === "true") { + enableSortButton() +} else { + disableSortButton() +}