From f870c0bbf681db8773a88ab8bae5c389ddee4bc0 Mon Sep 17 00:00:00 2001 From: Bauke Date: Thu, 27 Oct 2022 15:48:34 +0200 Subject: [PATCH] Add the options page. --- source/assets/down-arrow.svg | 3 + source/options/components/editor.ts | 216 ++++++++++++++++++ source/options/components/page-footer.ts | 34 +++ source/options/components/page-header.ts | 15 ++ source/options/components/page-main.ts | 146 ++++++++++++ source/options/components/usage.ts | 157 +++++++++++++ source/options/examples.ts | 35 +++ source/options/index.html | 21 ++ source/options/index.scss | 41 ++++ source/options/index.ts | 29 +++ source/options/scss/components/editor.scss | 54 +++++ .../options/scss/components/page-footer.scss | 9 + .../options/scss/components/page-header.scss | 22 ++ source/options/scss/components/page-main.scss | 38 +++ source/options/scss/components/usage.scss | 95 ++++++++ source/options/scss/love.scss | 45 ++++ source/options/scss/mixins.scss | 11 + source/options/scss/reset.scss | 12 + source/options/scss/variables.scss | 4 + source/types.d.ts | 6 + 20 files changed, 993 insertions(+) create mode 100644 source/assets/down-arrow.svg create mode 100644 source/options/components/editor.ts create mode 100644 source/options/components/page-footer.ts create mode 100644 source/options/components/page-header.ts create mode 100644 source/options/components/page-main.ts create mode 100644 source/options/components/usage.ts create mode 100644 source/options/examples.ts create mode 100644 source/options/index.html create mode 100644 source/options/index.scss create mode 100644 source/options/index.ts create mode 100644 source/options/scss/components/editor.scss create mode 100644 source/options/scss/components/page-footer.scss create mode 100644 source/options/scss/components/page-header.scss create mode 100644 source/options/scss/components/page-main.scss create mode 100644 source/options/scss/components/usage.scss create mode 100644 source/options/scss/love.scss create mode 100644 source/options/scss/mixins.scss create mode 100644 source/options/scss/reset.scss create mode 100644 source/options/scss/variables.scss diff --git a/source/assets/down-arrow.svg b/source/assets/down-arrow.svg new file mode 100644 index 0000000..42e91ab --- /dev/null +++ b/source/assets/down-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/source/options/components/editor.ts b/source/options/components/editor.ts new file mode 100644 index 0000000..4d4286a --- /dev/null +++ b/source/options/components/editor.ts @@ -0,0 +1,216 @@ +import {ConfirmButton} from '@holllo/preact-components'; +import {html} from 'htm/preact'; +import {Component} from 'preact'; +import browser from 'webextension-polyfill'; + +import { + matcherTypes, + narrowMatcherType, + narrowRedirectType, + parseRedirect, + Redirects, + RedirectParameters, + redirectTypes, +} from '../../redirect/exports.js'; + +type Props = { + id: string; + redirect?: Redirects; + removeRedirect: (id: string) => void; + saveRedirect: (redirect: Redirects) => void; +}; + +type State = { + id: string; + redirectValue: string; +} & RedirectParameters; + +export default class Editor extends Component { + defaultParameters: RedirectParameters; + + constructor(props: Props) { + super(props); + + this.defaultParameters = { + enabled: true, + matcherType: 'hostname', + matcherValue: '', + redirectType: 'simple', + redirectValue: '', + }; + + this.state = { + id: this.props.id, + ...this.parametersFromProps(), + }; + } + + onInput = (event: Event, input: 'matcher' | 'redirect') => { + const target = event.target as HTMLInputElement; + const value = target.value; + + if (input === 'matcher') { + this.setState({matcherValue: value}); + } else if (input === 'redirect') { + this.setState({redirectValue: value}); + } else { + throw new Error(`Unexpected input changed: ${input as string}`); + } + }; + + onSelectChange = (event: Event, select: 'matcher' | 'redirect') => { + const target = event.target as HTMLSelectElement; + const value = target.value; + + if (select === 'matcher' && narrowMatcherType(value)) { + this.setState({matcherType: value}); + } else if (select === 'redirect' && narrowRedirectType(value)) { + this.setState({redirectType: value}); + } else { + throw new Error(`${value} is not a valid MatcherType or RedirectType`); + } + }; + + onMatcherInput = (event: Event) => { + this.onInput(event, 'matcher'); + }; + + onMatcherTypeChange = (event: Event) => { + this.onSelectChange(event, 'matcher'); + }; + + onRedirectInput = (event: Event) => { + this.onInput(event, 'redirect'); + }; + + onRedirectTypeChange = (event: Event) => { + this.onSelectChange(event, 'redirect'); + }; + + parametersFromProps = (): RedirectParameters => { + const redirect = this.props.redirect; + const parameters = redirect?.parameters ?? {...this.defaultParameters}; + + return { + enabled: parameters.enabled, + matcherType: parameters.matcherType, + matcherValue: parameters.matcherValue, + redirectType: parameters.redirectType, + redirectValue: parameters.redirectValue, + }; + }; + + parseRedirect = (): Redirects => { + const redirect = parseRedirect(this.state, this.props.id); + if (redirect === undefined) { + throw new Error('Failed to parse redirect'); + } + + return redirect; + }; + + prepareForStorage = ( + parameters: RedirectParameters, + ): Record => { + const storage: Record = {}; + storage[this.props.id] = parameters; + return storage; + }; + + remove = async () => { + await browser.storage.local.remove(this.props.id); + this.props.removeRedirect(this.props.id); + }; + + save = async () => { + const redirect = this.parseRedirect(); + await browser.storage.local.set( + this.prepareForStorage(redirect.parameters), + ); + this.props.saveRedirect(redirect); + }; + + toggleEnabled = async () => { + const enabled = !this.state.enabled; + const storage = this.prepareForStorage(this.parametersFromProps()); + storage[this.props.id].enabled = enabled; + await browser.storage.local.set(storage); + this.setState({enabled}); + }; + + render() { + const {enabled, matcherType, matcherValue, redirectType, redirectValue} = + this.state; + + const matcherTypeOptions = matcherTypes.map( + (value) => html``, + ); + const redirectTypeOptions = redirectTypes.map( + (value) => html``, + ); + + return html` +
+ + + + + + + + + + + <${ConfirmButton} + attributes=${{title: 'Remove Redirect'}} + class="button destructive" + click=${this.remove} + confirmClass="confirm" + confirmText="✓" + text="✗" + timeout=${5 * 1000} + /> + + +
+ `; + } +} diff --git a/source/options/components/page-footer.ts b/source/options/components/page-footer.ts new file mode 100644 index 0000000..ef4a549 --- /dev/null +++ b/source/options/components/page-footer.ts @@ -0,0 +1,34 @@ +import {PrivacyLink} from '@holllo/preact-components'; +import {html} from 'htm/preact'; +import {Component} from 'preact'; +import browser from 'webextension-polyfill'; + +export class PageFooter extends Component { + render() { + const manifest = browser.runtime.getManifest(); + const version = manifest.version; + + const donateAttributes = { + href: 'https://liberapay.com/Holllo', + }; + const donateLink = html` + <${PrivacyLink} attributes="${donateAttributes}">Donate + `; + + const versionLinkAttributes = { + href: `https://git.bauke.xyz/Holllo/re-nav/releases/tag/${version}`, + }; + const versionLink = html` + <${PrivacyLink} attributes=${versionLinkAttributes}>v${version} + `; + + return html` +
+

+ ${donateLink} 💖 ${versionLink} © Holllo — Free and open-source, + forever. +

+
+ `; + } +} diff --git a/source/options/components/page-header.ts b/source/options/components/page-header.ts new file mode 100644 index 0000000..a55f013 --- /dev/null +++ b/source/options/components/page-header.ts @@ -0,0 +1,15 @@ +import {html} from 'htm/preact'; +import {Component} from 'preact'; + +export class PageHeader extends Component { + render() { + return html` + + `; + } +} diff --git a/source/options/components/page-main.ts b/source/options/components/page-main.ts new file mode 100644 index 0000000..f63c133 --- /dev/null +++ b/source/options/components/page-main.ts @@ -0,0 +1,146 @@ +import {html} from 'htm/preact'; +import {Component} from 'preact'; +import browser from 'webextension-polyfill'; + +import { + parseRedirect, + Redirect, + Redirects, + SimpleRedirect, +} from '../../redirect/exports.js'; + +import Editor from './editor.js'; +import Usage from './usage.js'; + +type Props = Record; + +type State = { + redirects: Redirects[]; +}; + +export class PageMain extends Component { + constructor(props: Props) { + super(props); + + this.state = { + redirects: [], + }; + } + + async componentDidMount() { + const redirects: Redirects[] = []; + for (const [id, parameters] of Object.entries( + await browser.storage.local.get(), + )) { + const redirect = parseRedirect(parameters, id); + if (redirect === undefined) { + continue; + } + + redirects.push(redirect); + } + + // Sort the redirects by: + // * Matcher Type + // * then Matcher Value + // * then Redirect Type + // * finally Redirect Value + redirects.sort((a, b) => { + const { + matcherType: mTypeA, + matcherValue: mValueA, + redirectType: rTypeA, + redirectValue: rValueA, + } = a.parameters; + const { + matcherType: mTypeB, + matcherValue: mValueB, + redirectType: rTypeB, + redirectValue: rValueB, + } = b.parameters; + + if (mTypeA !== mTypeB) { + return mTypeA.localeCompare(mTypeB); + } + + if (mValueA !== mValueB) { + return mValueA.localeCompare(mValueB); + } + + if (rTypeA !== rTypeB) { + return rTypeA.localeCompare(rTypeB); + } + + return rValueA.localeCompare(rValueB); + }); + this.setState({redirects}); + } + + addNewRedirect = () => { + this.setState({ + redirects: [ + new SimpleRedirect({ + enabled: true, + matcherType: 'hostname', + matcherValue: 'example.com', + redirectType: 'simple', + redirectValue: 'example.org', + }), + ...this.state.redirects, + ], + }); + }; + + removeRedirect = (id: string) => { + this.setState({ + redirects: this.state.redirects.filter((redirect) => redirect.id !== id), + }); + }; + + saveRedirect = (redirect: Redirects) => { + const redirectIndex = this.state.redirects.findIndex( + (found) => found.id === redirect.id, + ); + if (redirectIndex === -1) { + this.setState({ + redirects: [redirect, ...this.state.redirects], + }); + } else { + const redirects = [...this.state.redirects]; + redirects[redirectIndex] = redirect; + this.setState({redirects}); + } + }; + + render() { + const editors = this.state.redirects.map( + (redirect) => + html` + <${Editor} + key=${redirect.id} + id=${redirect.id} + redirect=${redirect} + removeRedirect=${this.removeRedirect} + saveRedirect=${this.saveRedirect} + /> + `, + ); + + if (editors.length === 0) { + this.addNewRedirect(); + } + + return html` +
+
+ + + ${editors} +
+ <${Usage} /> +
+ `; + } +} diff --git a/source/options/components/usage.ts b/source/options/components/usage.ts new file mode 100644 index 0000000..5b6ed6b --- /dev/null +++ b/source/options/components/usage.ts @@ -0,0 +1,157 @@ +import {html} from 'htm/preact'; +import {Component} from 'preact'; + +export default class Usage extends Component { + render() { + return html` +
+ How do I use Re-Nav? + +

Creating redirects:

+
    +
  • Click the green "Add new redirect" button.
  • +
  • Select a matcher type and enter what it should match on.
  • +
  • + Select a redirect type and enter where you want to be redirected. +
  • +
  • + See the "Matchers" and "Redirects" sections below for lists of + everything available with examples. +
  • +
+ +

Using redirects:

+
    +
  • + Any time you are navigated to a link by your browser, the URL will + first be checked and you will be redirected automatically. +
  • +
+ +

Editing redirects:

+
    +
  • + Changes to redirects are only saved when you click the save button. +
  • +
  • + To enable or disable a redirect, click the button with the circle. + If it's filled in the redirect is enabled. +
  • +
  • To remove a redirect click the red button with the ✗ twice.
  • +
+ +

Some miscellaneous notes:

+
    +
  • Only URLs starting with "http" will be checked.
  • +
  • + Navigation events won't be checked if it has been less than 100 + milliseconds since the last successful redirect. +
  • +
  • + A redirect will be cancelled if the exact same redirect happened + less than 30 seconds ago. This acts as a quick bypass so you don't + have to disable redirects in the options page whenever you don't + want to be redirected. +
  • +
+ +

As a quick-start you can also insert the examples from below:

+
    +
  • + Note that this will reload the page so make sure your redirects have + been saved. +
  • +
  • + +
  • +
+
+ +
+ Matchers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeMatch DirectiveMatch Examples
Hostnametildes.nethttp://www.tildes.net/1
https://tildes.net/~creative.timasomo
RegexHOL{3}Ohttps://git.bauke.xyz/holllo2
^https?://www\\.holllo\\.org/?$https://www.holllo.org/
+ +
    +
  1. + Hostname matchers always remove "www." automatically, for + convenience. +
  2. +
  3. + Regular expressions are always tested with global and + case-insensitive flags enabled. +
  4. +
+
+ +
+ Redirects + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeChange ToExample URLRedirected URL1
Hostnamenitter.nethttps://twitter.com/therealTDHhttps://nitter.net/therealTDH
r.nfhttps://www.reddit.com/r/TheDearHunterhttps://r.nf/r/TheDearHunter
Simplehttps://holllo.orghttps://holllo.org/homehttps://holllo.org
+ +
    +
  1. The bold highlighted text shows what will be changed.
  2. +
+
+ `; + } +} diff --git a/source/options/examples.ts b/source/options/examples.ts new file mode 100644 index 0000000..26ccd52 --- /dev/null +++ b/source/options/examples.ts @@ -0,0 +1,35 @@ +import {Redirect, RedirectParameters} from '../redirect/base.js'; + +const examples: RedirectParameters[] = [ + { + enabled: true, + matcherType: 'hostname', + matcherValue: 'twitter.com', + redirectType: 'hostname', + redirectValue: 'nitter.net', + }, + { + enabled: true, + matcherType: 'hostname', + matcherValue: 'reddit.com', + redirectType: 'hostname', + redirectValue: 'r.nf', + }, + { + enabled: true, + matcherType: 'regex', + matcherValue: '^https?://holllo\\.org/renav/?$', + redirectType: 'simple', + redirectValue: 'https://holllo.org/re-nav', + }, +]; + +export function generateExamples(): Record { + const storage: Record = {}; + for (const example of examples) { + const id = Redirect.generateId(); + storage[id] = example; + } + + return storage; +} diff --git a/source/options/index.html b/source/options/index.html new file mode 100644 index 0000000..cce0fbf --- /dev/null +++ b/source/options/index.html @@ -0,0 +1,21 @@ + + + + + + + + Re-Nav + + + + + + + + + + + diff --git a/source/options/index.scss b/source/options/index.scss new file mode 100644 index 0000000..4e10116 --- /dev/null +++ b/source/options/index.scss @@ -0,0 +1,41 @@ +@use '../../node_modules/modern-normalize/modern-normalize.css'; +@use 'scss/reset'; +@use 'scss/mixins'; +@use 'scss/love'; +@use 'scss/components/page-header'; +@use 'scss/components/page-main'; +@use 'scss/components/page-footer'; +@use 'scss/components/editor'; +@use 'scss/components/usage'; + +html { + font-size: 62.5%; +} + +body { + background-color: var(--db-1); + color: var(--df-1); + font-size: 1.5rem; + padding: 16px; +} + +a { + color: var(--da-3); + + &:hover { + color: var(--df-2); + } +} + +:focus { + outline-color: var(--df-1); + outline-width: 2px; +} + +.bold { + font-weight: bold; +} + +.center-text { + text-align: center; +} diff --git a/source/options/index.ts b/source/options/index.ts new file mode 100644 index 0000000..192595f --- /dev/null +++ b/source/options/index.ts @@ -0,0 +1,29 @@ +import {html} from 'htm/preact'; +import {Component, render} from 'preact'; +import browser from 'webextension-polyfill'; + +import {PageFooter} from './components/page-footer.js'; +import {PageHeader} from './components/page-header.js'; +import {PageMain} from './components/page-main.js'; +import {generateExamples} from './examples.js'; + +window.addEventListener('DOMContentLoaded', () => { + window.Holllo = { + async insertExamples() { + await browser.storage.local.set(generateExamples()); + location.reload(); + }, + }; + + render(html`<${OptionsPage} />`, document.body); +}); + +class OptionsPage extends Component { + render() { + return html` + <${PageHeader} /> + <${PageMain} /> + <${PageFooter} /> + `; + } +} diff --git a/source/options/scss/components/editor.scss b/source/options/scss/components/editor.scss new file mode 100644 index 0000000..a180932 --- /dev/null +++ b/source/options/scss/components/editor.scss @@ -0,0 +1,54 @@ +.editor { + align-items: center; + background-color: var(--db-2); + display: flex; + padding: 8px; + + .arrow-span { + aspect-ratio: 1; + align-items: center; + display: flex; + height: 100%; + font-weight: bold; + justify-content: center; + } + + .button { + aspect-ratio: 1; + height: 100%; + margin-left: 8px; + padding: 0; + + &.enabled { + background-color: var(--da-4); + } + + &.disabled { + background-color: var(--da-2); + } + } + + .input { + background-color: var(--db-1); + border: 1px solid var(--df-2); + color: var(--df-1); + height: 100%; + padding: 4px; + width: 100%; + } + + .select { + appearance: none; + background: url('../../../assets/down-arrow.svg') no-repeat center right; + background-size: 1.5rem; + background-color: var(--db-1); + border: 1px solid var(--df-2); + border-radius: 0; + color: var(--df-1); + cursor: pointer; + height: 100%; + margin-right: 8px; + padding: 4px 24px 4px 4px; + width: fit-content; + } +} diff --git a/source/options/scss/components/page-footer.scss b/source/options/scss/components/page-footer.scss new file mode 100644 index 0000000..0995430 --- /dev/null +++ b/source/options/scss/components/page-footer.scss @@ -0,0 +1,9 @@ +@use '../mixins'; + +.page-footer { + @include mixins.responsive-container; + + border: 1px solid var(--df-2); + margin-bottom: 16px; + padding: 16px; +} diff --git a/source/options/scss/components/page-header.scss b/source/options/scss/components/page-header.scss new file mode 100644 index 0000000..57c55fd --- /dev/null +++ b/source/options/scss/components/page-header.scss @@ -0,0 +1,22 @@ +@use '../mixins'; + +.page-header { + @include mixins.responsive-container; + + border: 1px solid var(--df-2); + margin-bottom: 16px; + + h1 { + align-items: center; + display: flex; + } + + img { + background-color: var(--df-2); + display: inline-block; + height: 4.5rem; + margin-right: 1rem; + padding: 8px; + width: 4.5rem; + } +} diff --git a/source/options/scss/components/page-main.scss b/source/options/scss/components/page-main.scss new file mode 100644 index 0000000..8ff8dee --- /dev/null +++ b/source/options/scss/components/page-main.scss @@ -0,0 +1,38 @@ +@use '../mixins'; + +.page-main { + @include mixins.responsive-container; + + display: grid; + gap: 16px; + margin-bottom: 16px; + + .button { + background-color: var(--da-3); + border: none; + color: var(--db-1); + cursor: pointer; + font-weight: bold; + + &.destructive { + background-color: var(--da-1); + } + + &.new-redirect { + background-color: var(--da-4); + padding: 8px; + width: fit-content; + } + + &:hover { + background-color: var(--df-2); + } + } + + .editors { + border: 1px solid var(--df-2); + display: grid; + gap: 16px; + padding: 16px; + } +} diff --git a/source/options/scss/components/usage.scss b/source/options/scss/components/usage.scss new file mode 100644 index 0000000..1d1ff49 --- /dev/null +++ b/source/options/scss/components/usage.scss @@ -0,0 +1,95 @@ +.usage { + border: 1px solid var(--df-2); + + &[open] { + summary { + background-color: var(--df-2); + color: var(--db-1); + } + + &:not(.table) summary { + margin-bottom: 16px; + } + + > :not(summary) { + padding-left: 16px; + padding-right: 16px; + } + } + + summary { + cursor: pointer; + font-weight: bold; + padding: 16px; + + &:hover { + background-color: var(--df-1); + color: var(--db-1); + } + } + + ul { + list-style: square; + margin: 4px 0 2rem 16px; + } + + table { + border-collapse: collapse; + width: 100%; + + &:not(:last-child) { + border-bottom: 1px solid var(--df-2); + } + } + + th { + border-bottom: 1px solid var(--df-2); + padding: 8px; + } + + td, + th { + &:not(:last-child) { + border-right: 1px solid var(--df-2); + } + } + + tr { + &.alt { + background-color: var(--db-2); + } + + &:not(:last-child) { + border-bottom: 1px solid var(--df-2); + } + + td { + padding: 4px; + + b { + color: var(--da-3); + } + } + } + + .footnotes { + margin-left: 12px; + padding: 8px; + + code { + background-color: var(--db-2); + } + + li { + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } + } + } + + .button { + padding: 4px; + } +} diff --git a/source/options/scss/love.scss b/source/options/scss/love.scss new file mode 100644 index 0000000..c7f7b0a --- /dev/null +++ b/source/options/scss/love.scss @@ -0,0 +1,45 @@ +/* + The Love Theme CSS Custom Properties + https://love.holllo.cc - version 0.1.0 + MIT license +*/ + +.love { + /* Love Dark */ + --df-1: #f2efff; + --df-2: #e6deff; + --db-1: #1f1731; + --db-2: #2a2041; + --da-1: #f99fb1; + --da-2: #faa56c; + --da-3: #d2b83a; + --da-4: #96c839; + --da-5: #3bd18a; + --da-6: #3ecdbf; + --da-7: #41c8e5; + --da-8: #98b9f8; + --da-9: #d5a6f8; + --da-10: #f99add; + --dg-1: #e2e2e2; + --dg-2: #c6c6c6; + --dg-3: #ababab; + + /* Love Light */ + --lf-1: #1f1731; + --lf-2: #2a2041; + --lb-1: #f2efff; + --lb-2: #e6deff; + --la-1: #8b123c; + --la-2: #6a3b11; + --la-3: #514610; + --la-4: #384d10; + --la-5: #115133; + --la-6: #124f49; + --la-7: #144d5a; + --la-8: #17477e; + --la-9: #6f1995; + --la-10: #81156a; + --lg-1: #1b1b1b; + --lg-2: #303030; + --lg-3: #474747; +} diff --git a/source/options/scss/mixins.scss b/source/options/scss/mixins.scss new file mode 100644 index 0000000..83905d2 --- /dev/null +++ b/source/options/scss/mixins.scss @@ -0,0 +1,11 @@ +@use 'variables'; + +@mixin responsive-container { + margin-left: auto; + margin-right: auto; + width: variables.$large-breakpoint; + + @media (max-width: variables.$large-breakpoint) { + width: 100%; + } +} diff --git a/source/options/scss/reset.scss b/source/options/scss/reset.scss new file mode 100644 index 0000000..3d792cd --- /dev/null +++ b/source/options/scss/reset.scss @@ -0,0 +1,12 @@ +h1, +h2, +h3, +h4, +h5, +ol, +ul, +li, +p { + margin: 0; + padding: 0; +} diff --git a/source/options/scss/variables.scss b/source/options/scss/variables.scss new file mode 100644 index 0000000..2b9ef21 --- /dev/null +++ b/source/options/scss/variables.scss @@ -0,0 +1,4 @@ +$small-breakpoint: 600px; +$medium-breakpoint: 900px; +$large-breakpoint: 1200px; +$extra-large-breakpoint: 1800px; diff --git a/source/types.d.ts b/source/types.d.ts index 17d41e9..b395317 100644 --- a/source/types.d.ts +++ b/source/types.d.ts @@ -16,5 +16,11 @@ declare global { readonly VITE_BROWSER: 'chromium' | 'firefox'; } + interface Window { + Holllo: { + insertExamples(): Promise; + }; + } + type HtmComponent = ReturnType; }