🔥 New UI and Madiator Finder Features

This commit is contained in:
Shiba 2022-04-30 12:23:03 +00:00
parent 81f1742289
commit 3ee7e530d6
6 changed files with 403 additions and 83 deletions

View file

@ -1,6 +1,6 @@
:root {
--color-master: #488e77;
--color-slave: #458593;
--color-master: #499375;
--color-slave: #43889d;
--color-error: rgb(245, 81, 69);
--color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave));
--color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%);

View file

@ -1,7 +1,7 @@
import { getExtensionSettingsAsync } from "./settings"
import { setSetting } from "./useSettings"
export async function generateKeys() {
async function generateKeys() {
const keys = await window.crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
@ -58,31 +58,154 @@ export async function sign(data: string, privateKey: string) {
)).toString('base64')
}
export async function loginAndSetNickname() {
const settings = await getExtensionSettingsAsync()
export function resetProfileSettings() {
setSetting('publicKey', null)
setSetting('privateKey', null)
}
let nickname;
while (true)
{
export async function generateProfileAndSetNickname(overwrite = false) {
let { publicKey, privateKey } = await getExtensionSettingsAsync()
let nickname
while (true) {
nickname = prompt("Pick a nickname")
if (nickname) break
if (nickname === null) return
alert("Invalid nickname")
}
if (!settings.privateKey || !settings.publicKey)
if (overwrite || !privateKey || !publicKey) {
resetProfileSettings()
await generateKeys().then((keys) => {
setSetting('publicKey', keys.publicKey)
setSetting('privateKey', keys.privateKey)
publicKey = keys.publicKey
privateKey = keys.privateKey
})
}
const url = new URL('https://finder.madiator.com/api/v1/profile')
url.searchParams.set('data', JSON.stringify({ nickname }))
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), settings.privateKey!),
publicKey: settings.publicKey
signature: await sign(url.searchParams.toString(), privateKey!),
publicKey
}))
const respond = await fetch(url.href, { method: "POST" })
if (respond.ok) alert(`Your nickname has been set to ${nickname}`)
if (respond.ok) {
setSetting('publicKey', publicKey)
setSetting('privateKey', privateKey)
alert(`Your nickname has been set to ${nickname}`)
}
else alert((await respond.json()).message)
}
export async function purgeProfile() {
try {
if (!confirm("This will purge all of your online and offline profile data.\nStill wanna continue?")) return
const settings = await getExtensionSettingsAsync()
if (!settings.privateKey || !settings.publicKey)
throw new Error('There is no profile to be purged.')
const url = new URL('https://finder.madiator.com/api/v1/profile/purge')
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), settings.privateKey!),
publicKey: settings.publicKey
}))
const respond = await fetch(url.href, { method: "POST" })
if (respond.ok) {
resetProfileSettings()
alert(`Your profile has been purged`)
}
else throw new Error((await respond.json()).message)
} catch (error: any) {
alert(error.message)
}
}
export async function getProfile() {
try {
const settings = await getExtensionSettingsAsync()
if (!settings.privateKey || !settings.publicKey)
throw new Error('There is no profile.')
const url = new URL('https://finder.madiator.com/api/v1/profile')
url.searchParams.set('data', JSON.stringify({ publicKey: settings.publicKey }))
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), settings.privateKey!),
publicKey: settings.publicKey
}))
const respond = await fetch(url.href, { method: "GET" })
if (respond.ok) {
const profile = await respond.json() as { nickname: string, score: number, publickKey: string }
return profile
}
else throw new Error((await respond.json()).message)
} catch (error: any) {
console.error(error)
}
}
export function friendlyPublicKey(publicKey: string | null) {
// This is copy paste of Madiator Finder's friendly public key
return publicKey?.substring(publicKey.length - 64, publicKey.length - 32)
}
function download(data: string, filename: string, type: string) {
const file = new Blob([data], { type: type })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
})
}
async function readFile() {
return await new Promise<string | null>((resolve) => {
const input = document.createElement("input")
input.type = 'file'
input.accept = '.wol-keys.json'
input.click()
input.addEventListener("change", () => {
if (!input.files?.[0]) return
const myFile = input.files[0]
const reader = new FileReader()
reader.addEventListener('load', () => resolve(reader.result?.toString() ?? null))
reader.readAsBinaryString(myFile)
})
})
}
interface ExportedProfileKeysFile {
publicKey: string
privateKey: string
}
export async function exportProfileKeysAsFile() {
const { publicKey, privateKey } = await getExtensionSettingsAsync()
const json = JSON.stringify({
publicKey,
privateKey
})
download(json, `watch-on-lbry-profile-export-${friendlyPublicKey(publicKey)}.wol-keys.json`, 'application/json')
}
export async function importProfileKeysFromFile() {
try {
const json = await readFile()
if (!json) throw new Error("Invalid")
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
setSetting('publicKey', publicKey)
setSetting('privateKey', privateKey)
} catch (error: any) {
alert(error.message)
}
}

View file

@ -1,5 +1,4 @@
import { useEffect, useReducer } from 'preact/hooks'
import { generateKeys } from './crypto'
import { DEFAULT_SETTINGS, ExtensionSettings } from './settings'
/**
@ -7,7 +6,7 @@ import { DEFAULT_SETTINGS, ExtensionSettings } from './settings'
*
* @param defaultSettings the default value. Must have all relevant keys present and should not change
*/
export function useSettings(defaultSettings: ExtensionSettings) {
function useSettings(defaultSettings: ExtensionSettings) {
const [state, dispatch] = useReducer((state, nstate: Partial<ExtensionSettings>) => ({ ...state, ...nstate }), defaultSettings)
const settingsKeys = Object.keys(defaultSettings)
// register change listeners, gets current values, and cleans up the listeners on unload

View file

@ -1,15 +1,55 @@
.popup {
width: 35em;
max-width: 100%;
overflow: hidden;
:root {
--color-master: #499375;
--color-slave: #43889d;
--color-error: rgb(245, 81, 69);
--color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave));
--color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%);
--color-dark: #0e1117;
--color-light: rgb(235, 237, 241);
--gradient-animation: gradient-animation 5s linear infinite alternate;
}
:root {
letter-spacing: .2ch;
}
body {
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
background-attachment: fixed;
color: var(--color-light);
padding: 0;
margin: 0;
}
body::before {
content: "";
position: absolute;
inset: 0;
background: rgba(19, 19, 19, 0.75);
}
*,
*::before,
*::after {
box-sizing: border-box;
position: relative;
}
label {
font-size: 2em;
font-size: 1.75em;
font-weight: bold;
text-align: center;
}
a {
cursor: pointer;
}
p {
margin: 0;
text-align: center;
}
header {
display: grid;
position: sticky;
@ -19,27 +59,99 @@ header {
padding: .5em .2em;
}
.button {
display: inline-flex;
place-items: center;
text-align: center;
padding: .5em 1em;
background: var(--color-dark);
color: var(--color-light);
cursor: pointer;
border-radius: .5em;
}
.filled {
background: var(--color-gradient-0);
background-clip: text;
-webkit-background-clip: text;
font-weight: bold;
color: transparent;
}
.button.active {
background: var(--color-gradient-0);
}
.button.active::before {
content: "";
position: absolute;
inset: 0;
background: var(--color-gradient-0);
filter: blur(.5em);
z-index: -1;
border-radius: .5em;
}
.button.disabled {
filter: saturate(0);
pointer-events: none;
}
.go-back {
color: currentColor;
text-decoration: none;
font-size: 1.5em;
}
.popup {
width: 35em;
max-width: 100%;
overflow: hidden;
}
.overlay {
display: grid;
place-items: center;
position: fixed;
inset: 0;
font-size: 2em;
font-weight: bold;
}
.overlay::before {
content: '';
position: absolute;
inset: 0;
background-color: #0e1117;
opacity: .75;
}
.profile {
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: .25em;
font-size: 1.5em;
}
.profile .name {
display: inline-block;
width: 10em;
max-width: 100%;
display: inline-flex;
gap: .5em;
place-items: center;
max-width: min(10em, 100%);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
color: currentColor;
}
.profile::before {
.profile .name::before {
content: '';
display: inline-block;
width: 4ch;
width: 1ch;
aspect-ratio: 1/1;
background: var(--color-gradient-0);
border-radius: 100000%;
@ -47,17 +159,14 @@ header {
main {
display: grid;
gap: 1em;
padding: .25em;
}
gap: 2em;
padding: 1.5em 0.5em;
}
section {
display: grid;
justify-items: center;
gap: .75em;
background: rgba(19, 19, 19, 0.75);
border-radius: 1em;
padding: .5em;
}
.options {
@ -66,4 +175,5 @@ section {
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
justify-content: center;
gap: .25em;
padding: 0 1.5em;
}

View file

@ -1,72 +1,162 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import '../common/common.css'
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto'
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings'
import { setSetting, useLbrySettings } from '../common/useSettings'
import '../common/common.css'
import './popup.css'
import { LbryPathnameCache } from '../common/yt/urlCache'
import { loginAndSetNickname } from '../common/crypto'
import './popup.css'
/** Gets all the options for redirect destinations as selection options */
const targetPlatforms = getTargetPlatfromSettingsEntiries()
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
function WatchOnLbryPopup() {
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings()
let [clearingCache, updateClearingCache] = useState(() => false)
let [loading, updateLoading] = useState(() => false)
let [popupRoute, updateRoute] = useState<string | null>(() => null)
const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...'
async function startAsyncOperation<T>(operation: Promise<T>) {
try {
updateLoading(true)
await operation
} catch (error) {
console.error(error)
}
finally {
updateLoading(false)
}
}
return <div className='popup'>
<header>
<div className='profile'>
{publicKey
? <span className='name'>{publicKey}</span>
: <a className='button' onClick={() => loginAndSetNickname()}>Login</a>}
? <span><b>Using As:</b> <a onClick={() => updateRoute('profile')} className='name link'>{nickname}</a></span>
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Your Profile</a>}
</div>
</header>
<main>
<section>
<label>Pick a mode !</label>
<div className='options'>
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
Redirect
</a>
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
Show a button
</a>
</div>
</section>
<section>
<label>Where would you like to redirect ?</label>
<div className='options'>
{targetPlatforms.map(([name, value]) =>
<a onClick={() => setSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
{value.displayName}
{
popupRoute === 'profile' ?
publicKey ?
<main>
<a onClick={() => updateRoute('')} className="go-back link"> Back</a>
<section>
<label>{nickname}</label>
<p>{friendlyPublicKey(publicKey)}</p>
<span><b>Score: {params.profile?.score ?? '...'}</b> - <a target='_blank' href="https://finder.madiator.com/leaderboard" class="filled">🔗Leaderboard</a></span>
<div className='options'>
<a onClick={() => startAsyncOperation(generateProfileAndSetNickname()).then(() => renderPopup())} className={`button active`}>
Change Nickname
</a>
<a onClick={() => confirm("This will delete your keypair from this device.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.") && resetProfileSettings() && renderPopup()} className={`button`}>
Forget/Logout
</a>
</div>
</section>
<section>
<label>Backup your account</label>
<p>Import and export your unique keypair.</p>
<div className='options'>
<a onClick={() => exportProfileKeysAsFile()} className={`button active`}>
Export
</a>
<a onClick={() => confirm("This will overwrite your old keypair.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.") && startAsyncOperation(importProfileKeysFromFile()).then(() => renderPopup())} className={`button`}>
Import
</a>
</div>
</section>
<section>
<label>Purge your profile and data!</label>
<p>Purge your profile data online and offline.</p>
<div className='options'>
<a className="button filled">(°° </a>
<a onClick={() => startAsyncOperation(purgeProfile()).then(() => renderPopup())} className={`button`}>
Purge Everything!!
</a>
</div>
</section>
<section>
<label>Generate new profile</label>
<p>Generate a new keypair.</p>
<div className='options'>
<a onClick={() => confirm("This will overwrite your old keypair.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.") && startAsyncOperation(generateProfileAndSetNickname(true)).then(() => renderPopup())} className={`button`}>
Generate New Account
</a>
</div>
</section>
</main>
:
<main>
<section>
<label>You don't have a profile.</label>
<p>You can either import keypair for an existing profile or generate a new profile keypair.</p>
<div className='options'>
<a onClick={() => startAsyncOperation(importProfileKeysFromFile()).then(() => renderPopup())} className={`button`}>
Import
</a>
<a onClick={() => startAsyncOperation(generateProfileAndSetNickname()).then(() => renderPopup())} className={`button active`}>
Generate
</a>
</div>
</section>
</main>
:
<main>
<section>
<label>Pick a mode:</label>
<div className='options'>
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
Redirect
</a>
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
Show a button
</a>
</div>
</section>
<section>
<label>Which platform you would like to redirect?</label>
<div className='options'>
{targetPlatforms.map(([name, value]) =>
<a onClick={() => setSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
{value.displayName}
</a>
)}
</div>
</section>
<section>
<label>Which resolver API you want to use?</label>
<div className='options'>
{ytUrlResolverOptions.map(([name, value]) =>
<a onClick={() => setSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
{value.name}
</a>
)}
</div>
<a onClick={() => startAsyncOperation(LbryPathnameCache.clearAll()).then(() => alert("Cleared Cache!"))} className={`button active`}>
Clear Resolver Cache
</a>
)}
</div>
</section>
<section>
<label>Which resolver API you want to use ?</label>
<div className='options'>
{ytUrlResolverOptions.map(([name, value]) =>
<a onClick={() => setSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
{value.name}
</section>
<section>
<label>Tools</label>
<a target='_blank' href='/tools/YTtoLBRY.html' className={`button filled`}>
Subscription Converter
</a>
)}
</div>
<a onClick={async () => {
updateClearingCache(true)
await LbryPathnameCache.clearAll()
updateClearingCache(false)
alert("Cleared Cache!")
}} className={`button active ${clearingCache ? 'disabled' : ''}`}>
Clear Resolver Cache
</a>
</section>
</main>
</section>
</main>
}
{loading && <div class="overlay">
<span>Loading...</span>
</div>}
</div>
}
render(<WatchOnLbryPopup />, document.getElementById('root')!)
function renderPopup() {
render(<WatchOnLbryPopup profile={null} />, document.getElementById('root')!)
getProfile().then((profile) => render(<WatchOnLbryPopup profile={profile} />, document.getElementById('root')!))
}
renderPopup()

View file

@ -1,6 +1,4 @@
import { generateKeys } from '../common/crypto'
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync } from '../common/settings'
import { setSetting } from '../common/useSettings'
/** Reset settings to default value and update the browser badge text */
async function initSettings() {