commit
5e916559b3
|
@ -103,6 +103,7 @@ And here I am. Building a Mastodon web client.
|
||||||
- [Soapbox](https://fe.soapbox.pub/)
|
- [Soapbox](https://fe.soapbox.pub/)
|
||||||
- [Elk](https://elk.zone/)
|
- [Elk](https://elk.zone/)
|
||||||
- [Mastodeck](https://mastodeck.com/)
|
- [Mastodeck](https://mastodeck.com/)
|
||||||
|
- [Trunks (alpha)](https://alpha.trunks.social/)
|
||||||
- [Tooty](https://github.com/n1k0/tooty)
|
- [Tooty](https://github.com/n1k0/tooty)
|
||||||
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||||
|
|
||||||
|
|
61
package-lock.json
generated
61
package-lock.json
generated
|
@ -11,7 +11,7 @@
|
||||||
"@formatjs/intl-localematcher": "~0.2.32",
|
"@formatjs/intl-localematcher": "~0.2.32",
|
||||||
"@github/text-expander-element": "~2.3.0",
|
"@github/text-expander-element": "~2.3.0",
|
||||||
"@iconify-icons/mingcute": "~1.2.4",
|
"@iconify-icons/mingcute": "~1.2.4",
|
||||||
"@szhsin/react-menu": "~3.5.1",
|
"@szhsin/react-menu": "~3.5.2",
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"p-retry": "~5.1.2",
|
"p-retry": "~5.1.2",
|
||||||
"p-throttle": "~5.0.0",
|
"p-throttle": "~5.0.0",
|
||||||
"preact": "~10.13.0",
|
"preact": "~10.13.1",
|
||||||
"react-hotkeys-hook": "~4.3.7",
|
"react-hotkeys-hook": "~4.3.7",
|
||||||
"react-intersection-observer": "~9.4.3",
|
"react-intersection-observer": "~9.4.3",
|
||||||
"react-router-dom": "6.6.2",
|
"react-router-dom": "6.6.2",
|
||||||
|
@ -31,6 +31,7 @@
|
||||||
"toastify-js": "~1.12.0",
|
"toastify-js": "~1.12.0",
|
||||||
"uid": "~2.0.1",
|
"uid": "~2.0.1",
|
||||||
"use-debounce": "~9.0.3",
|
"use-debounce": "~9.0.3",
|
||||||
|
"use-long-press": "~2.0.3",
|
||||||
"use-resize-observer": "~9.1.0",
|
"use-resize-observer": "~9.1.0",
|
||||||
"valtio": "1.9.0"
|
"valtio": "1.9.0"
|
||||||
},
|
},
|
||||||
|
@ -46,7 +47,7 @@
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-html-env": "~1.2.7",
|
"vite-plugin-html-env": "~1.2.7",
|
||||||
"vite-plugin-pwa": "~0.14.4",
|
"vite-plugin-pwa": "~0.14.4",
|
||||||
"vite-plugin-remove-console": "~2.0.0",
|
"vite-plugin-remove-console": "~2.1.0",
|
||||||
"workbox-cacheable-response": "~6.5.4",
|
"workbox-cacheable-response": "~6.5.4",
|
||||||
"workbox-expiration": "~6.5.4",
|
"workbox-expiration": "~6.5.4",
|
||||||
"workbox-routing": "~6.5.4",
|
"workbox-routing": "~6.5.4",
|
||||||
|
@ -2821,9 +2822,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@szhsin/react-menu": {
|
"node_modules/@szhsin/react-menu": {
|
||||||
"version": "3.5.1",
|
"version": "3.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz",
|
||||||
"integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==",
|
"integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-transition-state": "^1.1.5"
|
"react-transition-state": "^1.1.5"
|
||||||
|
@ -5658,9 +5659,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.13.0",
|
"version": "10.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz",
|
||||||
"integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==",
|
"integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ==",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
|
@ -6564,6 +6565,18 @@
|
||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-long-press": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10",
|
||||||
|
"npm": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-resize-observer": {
|
"node_modules/use-resize-observer": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
|
||||||
|
@ -6789,9 +6802,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-plugin-remove-console": {
|
"node_modules/vite-plugin-remove-console": {
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.1.0.tgz",
|
||||||
"integrity": "sha512-bEsyShSacsunbm0X1zaVliwgmWlsaBPLk7FN4wr2xQMs8zSZPSwpRNTT5UZiF0+cfMEkN4VVnofITawmT3pjgQ==",
|
"integrity": "sha512-cil+h4rX3fDnnKMt73fexMGkwRSOV08+lTAzLGTRjGyxs9Ync3fqPWxnGrngJY7LyMMt3kEKf0hNOi+1DQ0j2g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
|
@ -8954,9 +8967,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@szhsin/react-menu": {
|
"@szhsin/react-menu": {
|
||||||
"version": "3.5.1",
|
"version": "3.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz",
|
||||||
"integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==",
|
"integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-transition-state": "^1.1.5"
|
"react-transition-state": "^1.1.5"
|
||||||
|
@ -10964,9 +10977,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"preact": {
|
"preact": {
|
||||||
"version": "10.13.0",
|
"version": "10.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz",
|
||||||
"integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw=="
|
"integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ=="
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
|
@ -11609,6 +11622,12 @@
|
||||||
"integrity": "sha512-FhtlbDtDXILJV7Lix5OZj5yX/fW1tzq+VrvK1fnT2bUrPOGruU9Rw8NCEn+UI9wopfERBEZAOQ8lfeCJPllgnw==",
|
"integrity": "sha512-FhtlbDtDXILJV7Lix5OZj5yX/fW1tzq+VrvK1fnT2bUrPOGruU9Rw8NCEn+UI9wopfERBEZAOQ8lfeCJPllgnw==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"use-long-press": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"use-resize-observer": {
|
"use-resize-observer": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
|
||||||
|
@ -11744,9 +11763,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vite-plugin-remove-console": {
|
"vite-plugin-remove-console": {
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.1.0.tgz",
|
||||||
"integrity": "sha512-bEsyShSacsunbm0X1zaVliwgmWlsaBPLk7FN4wr2xQMs8zSZPSwpRNTT5UZiF0+cfMEkN4VVnofITawmT3pjgQ==",
|
"integrity": "sha512-cil+h4rX3fDnnKMt73fexMGkwRSOV08+lTAzLGTRjGyxs9Ync3fqPWxnGrngJY7LyMMt3kEKf0hNOi+1DQ0j2g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"webidl-conversions": {
|
"webidl-conversions": {
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"@formatjs/intl-localematcher": "~0.2.32",
|
"@formatjs/intl-localematcher": "~0.2.32",
|
||||||
"@github/text-expander-element": "~2.3.0",
|
"@github/text-expander-element": "~2.3.0",
|
||||||
"@iconify-icons/mingcute": "~1.2.4",
|
"@iconify-icons/mingcute": "~1.2.4",
|
||||||
"@szhsin/react-menu": "~3.5.1",
|
"@szhsin/react-menu": "~3.5.2",
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
|
@ -24,7 +24,7 @@
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"p-retry": "~5.1.2",
|
"p-retry": "~5.1.2",
|
||||||
"p-throttle": "~5.0.0",
|
"p-throttle": "~5.0.0",
|
||||||
"preact": "~10.13.0",
|
"preact": "~10.13.1",
|
||||||
"react-hotkeys-hook": "~4.3.7",
|
"react-hotkeys-hook": "~4.3.7",
|
||||||
"react-intersection-observer": "~9.4.3",
|
"react-intersection-observer": "~9.4.3",
|
||||||
"react-router-dom": "6.6.2",
|
"react-router-dom": "6.6.2",
|
||||||
|
@ -33,6 +33,7 @@
|
||||||
"toastify-js": "~1.12.0",
|
"toastify-js": "~1.12.0",
|
||||||
"uid": "~2.0.1",
|
"uid": "~2.0.1",
|
||||||
"use-debounce": "~9.0.3",
|
"use-debounce": "~9.0.3",
|
||||||
|
"use-long-press": "~2.0.3",
|
||||||
"use-resize-observer": "~9.1.0",
|
"use-resize-observer": "~9.1.0",
|
||||||
"valtio": "1.9.0"
|
"valtio": "1.9.0"
|
||||||
},
|
},
|
||||||
|
@ -48,7 +49,7 @@
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-html-env": "~1.2.7",
|
"vite-plugin-html-env": "~1.2.7",
|
||||||
"vite-plugin-pwa": "~0.14.4",
|
"vite-plugin-pwa": "~0.14.4",
|
||||||
"vite-plugin-remove-console": "~2.0.0",
|
"vite-plugin-remove-console": "~2.1.0",
|
||||||
"workbox-cacheable-response": "~6.5.4",
|
"workbox-cacheable-response": "~6.5.4",
|
||||||
"workbox-expiration": "~6.5.4",
|
"workbox-expiration": "~6.5.4",
|
||||||
"workbox-routing": "~6.5.4",
|
"workbox-routing": "~6.5.4",
|
||||||
|
|
18
scripts/fetch-lingva-languages.js
Normal file
18
scripts/fetch-lingva-languages.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Fetch https://lingva.ml/api/v1/languages/{source|target}
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
fetch('https://lingva.ml/api/v1/languages/source')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((json) => {
|
||||||
|
const file = './src/data/lingva-source-languages.json';
|
||||||
|
console.log(`Writing ${file}...`);
|
||||||
|
fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('https://lingva.ml/api/v1/languages/target')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((json) => {
|
||||||
|
const file = './src/data/lingva-target-languages.json';
|
||||||
|
console.log(`Writing ${file}...`);
|
||||||
|
fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
|
||||||
|
});
|
73
src/app.css
73
src/app.css
|
@ -74,8 +74,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
width: var(--main-width);
|
width: var(--main-width);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
.deck.contained {
|
.deck.contained {
|
||||||
|
@ -537,6 +535,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
transition: background-color 0.2s ease-out;
|
transition: background-color 0.2s ease-out;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
animation: appear 0.2s ease-out;
|
animation: appear 0.2s ease-out;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
:is(.status-link, .status-focus):is(:focus, .is-active) {
|
:is(.status-link, .status-focus):is(:focus, .is-active) {
|
||||||
background-color: var(--link-bg-hover-color);
|
background-color: var(--link-bg-hover-color);
|
||||||
|
@ -987,9 +986,9 @@ body:has(.status-deck) .media-post-link {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: calc(var(--main-width) - 50px - 16px);
|
max-width: calc(var(--main-width) - 50px - 16px);
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
box-shadow: 0 -1px 32px var(--divider-color);
|
box-shadow: 0 -1px 32px var(--drop-shadow-color);
|
||||||
animation: slide-up 0.3s var(--timing-function);
|
animation: slide-up 0.3s var(--timing-function);
|
||||||
border: 1px solid var(--outline-color);
|
/* border: 1px solid var(--outline-color); */
|
||||||
}
|
}
|
||||||
.sheet-max {
|
.sheet-max {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
|
@ -1007,6 +1006,12 @@ body:has(.status-deck) .media-post-link {
|
||||||
.sheet header :is(h1, h2, h3) {
|
.sheet header :is(h1, h2, h3) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.sheet header.header-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.sheet main {
|
.sheet main {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
@ -1045,6 +1050,11 @@ body:has(.status-deck) .media-post-link {
|
||||||
|
|
||||||
/* MENU POPUP */
|
/* MENU POPUP */
|
||||||
|
|
||||||
|
.szh-menu-container {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
.szh-menu-container:has(.szh-menu--state-open) {
|
.szh-menu-container:has(.szh-menu--state-open) {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
inset: env(safe-area-inset-top) env(safe-area-inset-right)
|
inset: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||||
|
@ -1053,7 +1063,7 @@ body:has(.status-deck) .media-post-link {
|
||||||
.szh-menu {
|
.szh-menu {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: var(--text-size);
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -1088,10 +1098,16 @@ body:has(.status-deck) .media-post-link {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 8px 16px !important;
|
padding: 8px 16px !important;
|
||||||
transition: all 0.1s ease-in-out;
|
transition: all 0.1s ease-in-out;
|
||||||
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-decoration: none;
|
}
|
||||||
|
.szh-menu .szh-menu__item span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
.szh-menu .szh-menu__item * {
|
.szh-menu .szh-menu__item * {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
@ -1106,6 +1122,7 @@ body:has(.status-deck) .media-post-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 8px 16px !important;
|
padding: 8px 16px !important;
|
||||||
margin: -8px -16px !important;
|
margin: -8px -16px !important;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.szh-menu .szh-menu__item a.is-active {
|
.szh-menu .szh-menu__item a.is-active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -1129,6 +1146,24 @@ body:has(.status-deck) .media-post-link {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.szh-menu .menu-double-lines {
|
||||||
|
white-space: normal;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.szh-menu .menu-double-lines span {
|
||||||
|
white-space: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.szh-menu .menu-horizontal {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.szh-menu .menu-horizontal .szh-menu__item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
.szh-menu .szh-menu__item .menu-shortcut {
|
.szh-menu .szh-menu__item .menu-shortcut {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
@ -1219,35 +1254,45 @@ meter.donut:is(.danger, .explode):after {
|
||||||
|
|
||||||
/* SHINY PILL */
|
/* SHINY PILL */
|
||||||
|
|
||||||
.shiny-pill {
|
:is(.shiny-pill, :root .toastify.shiny-pill) {
|
||||||
|
pointer-events: auto;
|
||||||
color: var(--button-text-color);
|
color: var(--button-text-color);
|
||||||
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
|
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
|
||||||
background-color: var(--button-bg-color);
|
background-color: var(--button-bg-color);
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
160deg,
|
160deg,
|
||||||
rgba(255, 255, 255, 0.5),
|
rgba(255, 255, 255, 0.5),
|
||||||
rgba(255, 255, 255, 0) 50%
|
rgba(0, 0, 0, 0.1)
|
||||||
);
|
);
|
||||||
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
|
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
|
||||||
0 10px 36px -4px var(--button-bg-blur-color),
|
0 10px 36px -4px var(--button-bg-blur-color),
|
||||||
inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5);
|
inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5);
|
||||||
|
transition: filter 0.3s;
|
||||||
|
}
|
||||||
|
:is(.shiny-pill, :root .toastify.shiny-pill):hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
:is(.shiny-pill, :root .toastify.shiny-pill):active {
|
||||||
|
transition: none;
|
||||||
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TOAST */
|
/* TOAST */
|
||||||
|
|
||||||
:root .toastify {
|
:root .toastify {
|
||||||
|
user-select: none;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--button-text-color);
|
||||||
|
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
|
||||||
|
background-color: var(--button-bg-blur-color);
|
||||||
|
background-image: none;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
.toastify-bottom {
|
.toastify-bottom {
|
||||||
margin-bottom: env(safe-area-inset-bottom);
|
margin-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
:root .toastify:hover {
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
:root .toastify:active {
|
|
||||||
filter: brightness(0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AVATARS STACK */
|
/* AVATARS STACK */
|
||||||
|
|
||||||
|
|
224
src/app.jsx
224
src/app.jsx
|
@ -16,7 +16,7 @@ import {
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Account from './components/account';
|
import AccountSheet from './components/account-sheet';
|
||||||
import Compose from './components/compose';
|
import Compose from './components/compose';
|
||||||
import Drafts from './components/drafts';
|
import Drafts from './components/drafts';
|
||||||
import Loader from './components/loader';
|
import Loader from './components/loader';
|
||||||
|
@ -26,6 +26,7 @@ import Shortcuts from './components/shortcuts';
|
||||||
import ShortcutsSettings from './components/shortcuts-settings';
|
import ShortcutsSettings from './components/shortcuts-settings';
|
||||||
import NotFound from './pages/404';
|
import NotFound from './pages/404';
|
||||||
import AccountStatuses from './pages/account-statuses';
|
import AccountStatuses from './pages/account-statuses';
|
||||||
|
import Accounts from './pages/accounts';
|
||||||
import Bookmarks from './pages/bookmarks';
|
import Bookmarks from './pages/bookmarks';
|
||||||
import Favourites from './pages/favourites';
|
import Favourites from './pages/favourites';
|
||||||
import FollowedHashtags from './pages/followed-hashtags';
|
import FollowedHashtags from './pages/followed-hashtags';
|
||||||
|
@ -73,6 +74,13 @@ function App() {
|
||||||
.querySelector('meta[name="color-scheme"]')
|
.querySelector('meta[name="color-scheme"]')
|
||||||
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
|
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
|
||||||
}
|
}
|
||||||
|
const textSize = store.local.get('textSize');
|
||||||
|
if (textSize) {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--text-size',
|
||||||
|
`${textSize}px`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -143,6 +151,8 @@ function App() {
|
||||||
// Focus first column
|
// Focus first column
|
||||||
columns.querySelector('.deck-container')?.focus?.();
|
columns.querySelector('.deck-container')?.focus?.();
|
||||||
} else {
|
} else {
|
||||||
|
const backDrop = document.querySelector('.deck-backdrop');
|
||||||
|
if (backDrop) return;
|
||||||
// Focus last deck
|
// Focus last deck
|
||||||
const pages = document.querySelectorAll('.deck-container');
|
const pages = document.querySelectorAll('.deck-container');
|
||||||
const page = pages[pages.length - 1]; // last one
|
const page = pages[pages.length - 1]; // last one
|
||||||
|
@ -163,6 +173,7 @@ function App() {
|
||||||
const showModal =
|
const showModal =
|
||||||
snapStates.showCompose ||
|
snapStates.showCompose ||
|
||||||
snapStates.showSettings ||
|
snapStates.showSettings ||
|
||||||
|
snapStates.showAccounts ||
|
||||||
snapStates.showAccount ||
|
snapStates.showAccount ||
|
||||||
snapStates.showDrafts ||
|
snapStates.showDrafts ||
|
||||||
snapStates.showMediaModal ||
|
snapStates.showMediaModal ||
|
||||||
|
@ -171,15 +182,6 @@ function App() {
|
||||||
if (!showModal) focusDeck();
|
if (!showModal) focusDeck();
|
||||||
}, [showModal]);
|
}, [showModal]);
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// // HACK: prevent this from running again due to HMR
|
|
||||||
// if (states.init) return;
|
|
||||||
// if (isLoggedIn) {
|
|
||||||
// requestAnimationFrame(startVisibility);
|
|
||||||
// states.init = true;
|
|
||||||
// }
|
|
||||||
// }, [isLoggedIn]);
|
|
||||||
|
|
||||||
// Notifications service
|
// Notifications service
|
||||||
// - WebSocket to receive notifications when page is visible
|
// - WebSocket to receive notifications when page is visible
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
|
@ -253,7 +255,9 @@ function App() {
|
||||||
return !/^\/(login|welcome)/.test(pathname);
|
return !/^\/(login|welcome)/.test(pathname);
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
useInterval(() => {
|
const lastCheckDate = useRef();
|
||||||
|
const checkForUpdates = () => {
|
||||||
|
lastCheckDate.current = Date.now();
|
||||||
console.log('✨ Check app update');
|
console.log('✨ Check app update');
|
||||||
fetch('./version.json')
|
fetch('./version.json')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
@ -263,7 +267,21 @@ function App() {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
}, visible && 1000 * 60 * 60); // 1 hour
|
};
|
||||||
|
useInterval(() => checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
|
||||||
|
usePageVisibility((visible) => {
|
||||||
|
if (visible) {
|
||||||
|
if (!lastCheckDate.current) {
|
||||||
|
checkForUpdates();
|
||||||
|
} else {
|
||||||
|
const diff = Date.now() - lastCheckDate.current;
|
||||||
|
if (diff > 1000 * 60 * 60) {
|
||||||
|
// 1 hour
|
||||||
|
checkForUpdates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -374,6 +392,21 @@ function App() {
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{!!snapStates.showAccounts && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
states.showAccounts = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Accounts
|
||||||
|
onClose={() => {
|
||||||
|
states.showAccounts = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
{!!snapStates.showAccount && (
|
{!!snapStates.showAccount && (
|
||||||
<Modal
|
<Modal
|
||||||
class="light"
|
class="light"
|
||||||
|
@ -383,11 +416,14 @@ function App() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Account
|
<AccountSheet
|
||||||
account={snapStates.showAccount?.account || snapStates.showAccount}
|
account={snapStates.showAccount?.account || snapStates.showAccount}
|
||||||
instance={snapStates.showAccount?.instance}
|
instance={snapStates.showAccount?.instance}
|
||||||
onClose={() => {
|
onClose={({ destination }) => {
|
||||||
states.showAccount = false;
|
states.showAccount = false;
|
||||||
|
if (destination) {
|
||||||
|
states.showAccounts = false;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -440,164 +476,4 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// let ws;
|
|
||||||
// async function startStream() {
|
|
||||||
// const { masto, instance } = api();
|
|
||||||
// if (
|
|
||||||
// ws &&
|
|
||||||
// (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
|
|
||||||
// ) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const stream = await masto.v1.stream.streamUser();
|
|
||||||
// console.log('STREAM START', { stream });
|
|
||||||
// ws = stream.ws;
|
|
||||||
|
|
||||||
// const handleNewStatus = debounce((status) => {
|
|
||||||
// console.log('UPDATE', status);
|
|
||||||
// if (document.visibilityState === 'hidden') return;
|
|
||||||
|
|
||||||
// const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
|
||||||
// const inHome = status.id === states.homeLast?.id;
|
|
||||||
// if (!inHomeNew && !inHome) {
|
|
||||||
// if (states.settings.boostsCarousel && status.reblog) {
|
|
||||||
// // do nothing
|
|
||||||
// } else {
|
|
||||||
// states.homeNew.unshift({
|
|
||||||
// id: status.id,
|
|
||||||
// reblog: status.reblog?.id,
|
|
||||||
// reply: !!status.inReplyToAccountId,
|
|
||||||
// });
|
|
||||||
// console.log('homeNew 1', [...states.homeNew]);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// saveStatus(status, instance);
|
|
||||||
// }, 5000);
|
|
||||||
// stream.on('update', handleNewStatus);
|
|
||||||
// stream.on('status.update', (status) => {
|
|
||||||
// console.log('STATUS.UPDATE', status);
|
|
||||||
// saveStatus(status, instance);
|
|
||||||
// });
|
|
||||||
// stream.on('delete', (statusID) => {
|
|
||||||
// console.log('DELETE', statusID);
|
|
||||||
// // delete states.statuses[statusID];
|
|
||||||
// const s = getStatus(statusID);
|
|
||||||
// if (s) s._deleted = true;
|
|
||||||
// });
|
|
||||||
// stream.on('notification', (notification) => {
|
|
||||||
// console.log('NOTIFICATION', notification);
|
|
||||||
|
|
||||||
// const inNotificationsNew = states.notificationsNew.find(
|
|
||||||
// (n) => n.id === notification.id,
|
|
||||||
// );
|
|
||||||
// const inNotifications = notification.id === states.notificationsLast?.id;
|
|
||||||
// if (!inNotificationsNew && !inNotifications) {
|
|
||||||
// states.notificationsNew.unshift(notification);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// saveStatus(notification.status, instance, { override: false });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// stream.ws.onclose = () => {
|
|
||||||
// console.log('STREAM CLOSED!');
|
|
||||||
// if (document.visibilityState !== 'hidden') {
|
|
||||||
// startStream();
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// stream,
|
|
||||||
// stopStream: () => {
|
|
||||||
// stream.ws.close();
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let lastHidden;
|
|
||||||
// function startVisibility() {
|
|
||||||
// const { masto, instance } = api();
|
|
||||||
// const handleVisible = (visible) => {
|
|
||||||
// if (!visible) {
|
|
||||||
// const timestamp = Date.now();
|
|
||||||
// lastHidden = timestamp;
|
|
||||||
// } else {
|
|
||||||
// const timestamp = Date.now();
|
|
||||||
// const diff = timestamp - lastHidden;
|
|
||||||
// const diffMins = Math.round(diff / 1000 / 60);
|
|
||||||
// console.log(`visible: ${visible}`, { lastHidden, diffMins });
|
|
||||||
// if (!lastHidden || diffMins > 1) {
|
|
||||||
// (async () => {
|
|
||||||
// try {
|
|
||||||
// const firstStatusID = states.homeLast?.id;
|
|
||||||
// const firstNotificationID = states.notificationsLast?.id;
|
|
||||||
// console.log({ states, firstNotificationID, firstStatusID });
|
|
||||||
// const fetchHome = masto.v1.timelines.listHome({
|
|
||||||
// limit: 5,
|
|
||||||
// ...(firstStatusID && { sinceId: firstStatusID }),
|
|
||||||
// });
|
|
||||||
// const fetchNotifications = masto.v1.notifications.list({
|
|
||||||
// limit: 1,
|
|
||||||
// ...(firstNotificationID && { sinceId: firstNotificationID }),
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const newStatuses = await fetchHome;
|
|
||||||
// const hasOneAndReblog =
|
|
||||||
// newStatuses.length === 1 && newStatuses?.[0]?.reblog;
|
|
||||||
// if (newStatuses.length) {
|
|
||||||
// if (states.settings.boostsCarousel && hasOneAndReblog) {
|
|
||||||
// // do nothing
|
|
||||||
// } else {
|
|
||||||
// states.homeNew = newStatuses.map((status) => {
|
|
||||||
// saveStatus(status, instance);
|
|
||||||
// return {
|
|
||||||
// id: status.id,
|
|
||||||
// reblog: status.reblog?.id,
|
|
||||||
// reply: !!status.inReplyToAccountId,
|
|
||||||
// };
|
|
||||||
// });
|
|
||||||
// console.log('homeNew 2', [...states.homeNew]);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newNotifications = await fetchNotifications;
|
|
||||||
// if (newNotifications.length) {
|
|
||||||
// const notification = newNotifications[0];
|
|
||||||
// const inNotificationsNew = states.notificationsNew.find(
|
|
||||||
// (n) => n.id === notification.id,
|
|
||||||
// );
|
|
||||||
// const inNotifications =
|
|
||||||
// notification.id === states.notificationsLast?.id;
|
|
||||||
// if (!inNotificationsNew && !inNotifications) {
|
|
||||||
// states.notificationsNew.unshift(notification);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// saveStatus(notification.status, instance, { override: false });
|
|
||||||
// }
|
|
||||||
// } catch (e) {
|
|
||||||
// // Silently fail
|
|
||||||
// console.error(e);
|
|
||||||
// } finally {
|
|
||||||
// startStream();
|
|
||||||
// }
|
|
||||||
// })();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleVisibilityChange = () => {
|
|
||||||
// const hidden = document.visibilityState === 'hidden';
|
|
||||||
// handleVisible(!hidden);
|
|
||||||
// console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
|
|
||||||
// };
|
|
||||||
// document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
// requestAnimationFrame(handleVisibilityChange);
|
|
||||||
// return {
|
|
||||||
// stop: () => {
|
|
||||||
// document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
export { App };
|
export { App };
|
||||||
|
|
5
src/assets/floating-button.svg
Normal file
5
src/assets/floating-button.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 84 62">
|
||||||
|
<rect width="64" height="48" x="18" y="2" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||||
|
<rect width="32" height="48" x="2" y="12" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||||
|
<path fill="#4169E1" d="M14 52a4 4 0 1 1-8 0 4 4 0 0 1 8 0Zm64-42a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 371 B |
6
src/assets/multi-column.svg
Normal file
6
src/assets/multi-column.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 82 62">
|
||||||
|
<rect width="78" height="58" x="2" y="2" fill="#999" fill-opacity=".3" stroke="#999" stroke-width="3" rx="4"/>
|
||||||
|
<rect width="18" height="46" x="8" y="8" fill="#fff" stroke="#999" stroke-width="2" rx="1"/>
|
||||||
|
<rect width="18" height="46" x="32" y="8" fill="#fff" stroke="#999" stroke-width="2" rx="1"/>
|
||||||
|
<rect width="18" height="46" x="56" y="8" fill="#fff" stroke="#999" stroke-width="2" rx="1"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 479 B |
10
src/assets/tab-menu-bar.svg
Normal file
10
src/assets/tab-menu-bar.svg
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 84 62">
|
||||||
|
<rect width="64" height="48" x="18" y="2" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||||
|
<path fill="#999" fill-opacity=".3" d="M19 3h62v10H19z"/>
|
||||||
|
<path stroke="#4169E1" stroke-width="2" d="M43 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
|
<path stroke="#999" stroke-width="2" d="M52 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm9 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
|
<rect width="32" height="48" x="2" y="12" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||||
|
<path fill="#999" fill-opacity=".3" d="M3 49h30v10H3z"/>
|
||||||
|
<path stroke="#4169E1" stroke-width="2" d="M11 54a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
|
<path stroke="#999" stroke-width="2" d="M20 54a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm9 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 784 B |
|
@ -1,6 +1,9 @@
|
||||||
import './account-block.css';
|
import './account-block.css';
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import emojifyText from '../utils/emojify-text';
|
import emojifyText from '../utils/emojify-text';
|
||||||
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
|
@ -11,7 +14,9 @@ function AccountBlock({
|
||||||
avatarSize = 'xl',
|
avatarSize = 'xl',
|
||||||
instance,
|
instance,
|
||||||
external,
|
external,
|
||||||
|
internal,
|
||||||
onClick,
|
onClick,
|
||||||
|
showActivity = false,
|
||||||
}) {
|
}) {
|
||||||
if (skeleton) {
|
if (skeleton) {
|
||||||
return (
|
return (
|
||||||
|
@ -20,15 +25,28 @@ function AccountBlock({
|
||||||
<span>
|
<span>
|
||||||
<b>████████</b>
|
<b>████████</b>
|
||||||
<br />
|
<br />
|
||||||
@██████
|
<span class="account-block-acct">@██████</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { acct, avatar, avatarStatic, displayName, username, emojis, url } =
|
const navigate = useNavigate();
|
||||||
account;
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
acct,
|
||||||
|
avatar,
|
||||||
|
avatarStatic,
|
||||||
|
displayName,
|
||||||
|
username,
|
||||||
|
emojis,
|
||||||
|
url,
|
||||||
|
statusesCount,
|
||||||
|
lastStatusAt,
|
||||||
|
} = account;
|
||||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||||
|
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
@ -40,10 +58,14 @@ function AccountBlock({
|
||||||
if (external) return;
|
if (external) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onClick) return onClick(e);
|
if (onClick) return onClick(e);
|
||||||
states.showAccount = {
|
if (internal) {
|
||||||
account,
|
navigate(`/${instance}/a/${id}`);
|
||||||
instance,
|
} else {
|
||||||
};
|
states.showAccount = {
|
||||||
|
account,
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar url={avatar} size={avatarSize} />
|
<Avatar url={avatar} size={avatarSize} />
|
||||||
|
@ -57,7 +79,29 @@ function AccountBlock({
|
||||||
) : (
|
) : (
|
||||||
<b>{username}</b>
|
<b>{username}</b>
|
||||||
)}
|
)}
|
||||||
<br />@{acct}
|
<br />
|
||||||
|
<span class="account-block-acct">
|
||||||
|
@{acct1}
|
||||||
|
<wbr />
|
||||||
|
{acct2}
|
||||||
|
</span>
|
||||||
|
{showActivity && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<small class="last-status-at insignificant">
|
||||||
|
Posts: {statusesCount}
|
||||||
|
{!!lastStatusAt && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Last posted:{' '}
|
||||||
|
{niceDateTime(lastStatusAt, {
|
||||||
|
hideTime: true,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
290
src/components/account-info.css
Normal file
290
src/components/account-info.css
Normal file
|
@ -0,0 +1,290 @@
|
||||||
|
.account-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container.skeleton {
|
||||||
|
color: var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .header-banner {
|
||||||
|
/* pointer-events: none; */
|
||||||
|
aspect-ratio: 6 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
/* mask fade out bottom of banner */
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
hsl(0, 0%, 0%) 0%,
|
||||||
|
hsla(0, 0%, 0%, 0.987) 14%,
|
||||||
|
hsla(0, 0%, 0%, 0.951) 26.2%,
|
||||||
|
hsla(0, 0%, 0%, 0.896) 36.8%,
|
||||||
|
hsla(0, 0%, 0%, 0.825) 45.9%,
|
||||||
|
hsla(0, 0%, 0%, 0.741) 53.7%,
|
||||||
|
hsla(0, 0%, 0%, 0.648) 60.4%,
|
||||||
|
hsla(0, 0%, 0%, 0.55) 66.2%,
|
||||||
|
hsla(0, 0%, 0%, 0.45) 71.2%,
|
||||||
|
hsla(0, 0%, 0%, 0.352) 75.6%,
|
||||||
|
hsla(0, 0%, 0%, 0.259) 79.6%,
|
||||||
|
hsla(0, 0%, 0%, 0.175) 83.4%,
|
||||||
|
hsla(0, 0%, 0%, 0.104) 87.2%,
|
||||||
|
hsla(0, 0%, 0%, 0.049) 91.1%,
|
||||||
|
hsla(0, 0%, 0%, 0.013) 95.3%,
|
||||||
|
hsla(0, 0%, 0%, 0) 100%
|
||||||
|
);
|
||||||
|
margin-bottom: -44px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
.account-container .header-banner.header-is-avatar {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
hsl(0, 0%, 0%) 0%,
|
||||||
|
hsla(0, 0%, 0%, 0.987) 8.1%,
|
||||||
|
hsla(0, 0%, 0%, 0.951) 15.5%,
|
||||||
|
hsla(0, 0%, 0%, 0.896) 22.5%,
|
||||||
|
hsla(0, 0%, 0%, 0.825) 29%,
|
||||||
|
hsla(0, 0%, 0%, 0.741) 35.3%,
|
||||||
|
hsla(0, 0%, 0%, 0.648) 41.2%,
|
||||||
|
hsla(0, 0%, 0%, 0.55) 47.1%,
|
||||||
|
hsla(0, 0%, 0%, 0.45) 52.9%,
|
||||||
|
hsla(0, 0%, 0%, 0.352) 58.8%,
|
||||||
|
hsla(0, 0%, 0%, 0.259) 64.7%,
|
||||||
|
hsla(0, 0%, 0%, 0.175) 71%,
|
||||||
|
hsla(0, 0%, 0%, 0.104) 77.5%,
|
||||||
|
hsla(0, 0%, 0%, 0.049) 84.5%,
|
||||||
|
hsla(0, 0%, 0%, 0.013) 91.9%,
|
||||||
|
hsla(0, 0%, 0%, 0) 100%
|
||||||
|
);
|
||||||
|
filter: blur(32px) saturate(3) opacity(0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.account-container .header-banner:hover {
|
||||||
|
animation: position-object 5s ease-in-out 1s 5;
|
||||||
|
}
|
||||||
|
.account-container .header-banner:active {
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
.account-container .header-banner:active + header .avatar + * {
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
.account-container .header-banner:active + header .avatar {
|
||||||
|
transition: filter 0.3s ease-in-out;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
.account-container .header-banner:active + header .avatar img {
|
||||||
|
transition: border-radius 0.3s ease-in-out;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-height: 480px) {
|
||||||
|
.account-container .header-banner:not(.header-is-avatar) {
|
||||||
|
aspect-ratio: 3 / 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-shadow: -8px 0 12px -6px var(--bg-color), 8px 0 12px -6px var(--bg-color),
|
||||||
|
-8px 0 24px var(--header-color-3, --bg-color),
|
||||||
|
8px 0 24px var(--header-color-4, --bg-color);
|
||||||
|
animation: fade-in 0.3s both ease-in-out 0.1s;
|
||||||
|
}
|
||||||
|
.account-container header .avatar {
|
||||||
|
/* box-shadow: -8px 0 24px var(--header-color-3, --bg-color),
|
||||||
|
8px 0 24px var(--header-color-4, --bg-color); */
|
||||||
|
overflow: initial;
|
||||||
|
filter: drop-shadow(-2px 0 4px var(--header-color-3, --bg-color))
|
||||||
|
drop-shadow(2px 0 4px var(--header-color-4, --bg-color));
|
||||||
|
}
|
||||||
|
.account-container header .avatar:not(.has-alpha) img {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container main > *:first-child {
|
||||||
|
animation: fade-in 0.3s both ease-in-out 0.15s;
|
||||||
|
}
|
||||||
|
.account-container main > *:first-child ~ * {
|
||||||
|
animation: fade-in 0.3s both ease-in-out 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .note {
|
||||||
|
font-size: 95%;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.account-container .note:not(:has(p)):not(:empty) {
|
||||||
|
/* Some notes don't have <p> tags, so we need to add some padding */
|
||||||
|
padding: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 16px;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 90%;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.account-container .stats > * {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.account-container .stats a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 2.5em;
|
||||||
|
}
|
||||||
|
.account-container .actions button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .profile-metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.account-container .profile-field {
|
||||||
|
min-width: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: 90%;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
filter: saturate(0.75);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container :is(.note, .profile-field) .invisible {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.account-container :is(.note, .profile-field) .ellipsis::after {
|
||||||
|
content: '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .profile-field b {
|
||||||
|
font-size: 90%;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.account-container .profile-field b .icon {
|
||||||
|
color: var(--green-color);
|
||||||
|
}
|
||||||
|
.account-container .profile-field p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-container .common-followers {
|
||||||
|
border-top: 1px solid var(--outline-color);
|
||||||
|
border-bottom: 1px solid var(--outline-color);
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 90%;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-start .account-container {
|
||||||
|
border-bottom: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
.timeline-start .account-container header {
|
||||||
|
padding: 16px 16px 1px;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.timeline-start .account-container main {
|
||||||
|
padding: 1px 16px 1px;
|
||||||
|
}
|
||||||
|
.timeline-start .account-container main > * {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.timeline-start .account-container .account-block .account-block-acct {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.timeline-start .account-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.timeline-start .account-container:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
100deg,
|
||||||
|
rgba(255, 255, 255, 0) 30%,
|
||||||
|
rgba(255, 255, 255, 0.25),
|
||||||
|
rgba(255, 255, 255, 0) 70%
|
||||||
|
);
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.timeline-start .account-container:before {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.timeline-start .account-container:hover:before {
|
||||||
|
animation: shine 1s ease-in-out 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
.timeline-start .account-container {
|
||||||
|
--item-radius: 16px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
margin: 16px 0;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-radius: var(--item-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
/* box-shadow: 0px 1px var(--bg-blur-color), 0 0 64px var(--bg-color); */
|
||||||
|
--shadow-offset: 16px;
|
||||||
|
--shadow-blur: 32px;
|
||||||
|
--shadow-spread: calc(var(--shadow-blur) * -0.75);
|
||||||
|
box-shadow: calc(var(--shadow-offset) * -1) var(--shadow-offset)
|
||||||
|
var(--shadow-blur) var(--shadow-spread)
|
||||||
|
var(--header-color-1, var(--drop-shadow-color)),
|
||||||
|
var(--shadow-offset) var(--shadow-offset) var(--shadow-blur)
|
||||||
|
var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color));
|
||||||
|
}
|
||||||
|
.timeline-start .account-container .header-banner {
|
||||||
|
margin-bottom: -77px;
|
||||||
|
}
|
||||||
|
.timeline-start .account-container header .account-block {
|
||||||
|
font-size: 175%;
|
||||||
|
margin-bottom: -8px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.timeline-start .account-container header .account-block .avatar {
|
||||||
|
width: 112px !important;
|
||||||
|
height: 112px !important;
|
||||||
|
filter: drop-shadow(-8px 0 8px var(--header-color-3, --bg-color))
|
||||||
|
drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
import './account.css';
|
import './account-info.css';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import emojifyText from '../utils/emojify-text';
|
import emojifyText from '../utils/emojify-text';
|
||||||
|
@ -17,49 +16,36 @@ import Avatar from './avatar';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
|
|
||||||
function Account({ account, instance: propInstance, onClose }) {
|
function AccountInfo({
|
||||||
const { masto, instance, authenticated } = api({ instance: propInstance });
|
account,
|
||||||
|
fetchAccount = () => {},
|
||||||
|
standalone,
|
||||||
|
instance,
|
||||||
|
authenticated,
|
||||||
|
}) {
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
const isString = typeof account === 'string';
|
const isString = typeof account === 'string';
|
||||||
const [info, setInfo] = useState(isString ? null : account);
|
const [info, setInfo] = useState(isString ? null : account);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isString) {
|
if (!isString) {
|
||||||
setUIState('loading');
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const info = await masto.v1.accounts.lookup({
|
|
||||||
acct: account,
|
|
||||||
skip_webfinger: false,
|
|
||||||
});
|
|
||||||
setInfo(info);
|
|
||||||
setUIState('default');
|
|
||||||
} catch (e) {
|
|
||||||
try {
|
|
||||||
const result = await masto.v2.search({
|
|
||||||
q: account,
|
|
||||||
type: 'accounts',
|
|
||||||
limit: 1,
|
|
||||||
resolve: authenticated,
|
|
||||||
});
|
|
||||||
if (result.accounts.length) {
|
|
||||||
setInfo(result.accounts[0]);
|
|
||||||
setUIState('default');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInfo(null);
|
|
||||||
setUIState('error');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setInfo(null);
|
|
||||||
setUIState('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
} else {
|
|
||||||
setInfo(account);
|
setInfo(account);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [account]);
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const info = await fetchAccount();
|
||||||
|
states.accounts[`${info.id}@${instance}`] = info;
|
||||||
|
setInfo(info);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setInfo(null);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isString, account, fetchAccount]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
acct,
|
acct,
|
||||||
|
@ -73,8 +59,8 @@ function Account({ account, instance: propInstance, onClose }) {
|
||||||
followersCount,
|
followersCount,
|
||||||
followingCount,
|
followingCount,
|
||||||
group,
|
group,
|
||||||
header,
|
// header,
|
||||||
headerStatic,
|
// headerStatic,
|
||||||
id,
|
id,
|
||||||
lastStatusAt,
|
lastStatusAt,
|
||||||
locked,
|
locked,
|
||||||
|
@ -83,14 +69,29 @@ function Account({ account, instance: propInstance, onClose }) {
|
||||||
url,
|
url,
|
||||||
username,
|
username,
|
||||||
} = info || {};
|
} = info || {};
|
||||||
|
let headerIsAvatar = false;
|
||||||
|
let { header, headerStatic } = info || {};
|
||||||
|
if (!header || /missing\.png$/.test(header)) {
|
||||||
|
if (avatar && !/missing\.png$/.test(avatar)) {
|
||||||
|
header = avatar;
|
||||||
|
headerIsAvatar = true;
|
||||||
|
if (avatarStatic && !/missing\.png$/.test(avatarStatic)) {
|
||||||
|
headerStatic = avatarStatic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const escRef = useHotkeys('esc', onClose, [onClose]);
|
const [headerCornerColors, setHeaderCornerColors] = useState([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={escRef}
|
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
|
||||||
id="account-container"
|
style={{
|
||||||
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`}
|
'--header-color-1': headerCornerColors[0],
|
||||||
|
'--header-color-2': headerCornerColors[1],
|
||||||
|
'--header-color-3': headerCornerColors[2],
|
||||||
|
'--header-color-4': headerCornerColors[3],
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{uiState === 'error' && (
|
{uiState === 'error' && (
|
||||||
<div class="ui-state">
|
<div class="ui-state">
|
||||||
|
@ -113,21 +114,129 @@ function Account({ account, instance: propInstance, onClose }) {
|
||||||
<p>███████████████ ███████████████</p>
|
<p>███████████████ ███████████████</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="stats">
|
<p class="stats">
|
||||||
<span>██ Posts</span>
|
<span>
|
||||||
<span>██ Following</span>
|
Posts
|
||||||
<span>██ Followers</span>
|
<br />
|
||||||
|
██
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Following
|
||||||
|
<br />
|
||||||
|
██
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Followers
|
||||||
|
<br />
|
||||||
|
██
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
info && (
|
info && (
|
||||||
<>
|
<>
|
||||||
|
{header && !/missing\.png$/.test(header) && (
|
||||||
|
<img
|
||||||
|
src={header}
|
||||||
|
alt=""
|
||||||
|
class={`header-banner ${
|
||||||
|
headerIsAvatar ? 'header-is-avatar' : ''
|
||||||
|
}`}
|
||||||
|
onError={(e) => {
|
||||||
|
if (e.target.crossOrigin) {
|
||||||
|
if (e.target.src !== headerStatic) {
|
||||||
|
e.target.src = headerStatic;
|
||||||
|
} else {
|
||||||
|
e.target.removeAttribute('crossorigin');
|
||||||
|
e.target.src = header;
|
||||||
|
}
|
||||||
|
} else if (e.target.src !== headerStatic) {
|
||||||
|
e.target.src = headerStatic;
|
||||||
|
} else {
|
||||||
|
e.target.remove();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
onLoad={(e) => {
|
||||||
|
try {
|
||||||
|
// Get color from four corners of image
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = e.target.width;
|
||||||
|
canvas.height = e.target.height;
|
||||||
|
ctx.drawImage(e.target, 0, 0);
|
||||||
|
// const colors = [
|
||||||
|
// ctx.getImageData(0, 0, 1, 1).data,
|
||||||
|
// ctx.getImageData(e.target.width - 1, 0, 1, 1).data,
|
||||||
|
// ctx.getImageData(0, e.target.height - 1, 1, 1).data,
|
||||||
|
// ctx.getImageData(
|
||||||
|
// e.target.width - 1,
|
||||||
|
// e.target.height - 1,
|
||||||
|
// 1,
|
||||||
|
// 1,
|
||||||
|
// ).data,
|
||||||
|
// ];
|
||||||
|
// Get 10x10 pixels from corners, get average color from each
|
||||||
|
const pixelDimension = 10;
|
||||||
|
const colors = [
|
||||||
|
ctx.getImageData(0, 0, pixelDimension, pixelDimension)
|
||||||
|
.data,
|
||||||
|
ctx.getImageData(
|
||||||
|
e.target.width - pixelDimension,
|
||||||
|
0,
|
||||||
|
pixelDimension,
|
||||||
|
pixelDimension,
|
||||||
|
).data,
|
||||||
|
ctx.getImageData(
|
||||||
|
0,
|
||||||
|
e.target.height - pixelDimension,
|
||||||
|
pixelDimension,
|
||||||
|
pixelDimension,
|
||||||
|
).data,
|
||||||
|
ctx.getImageData(
|
||||||
|
e.target.width - pixelDimension,
|
||||||
|
e.target.height - pixelDimension,
|
||||||
|
pixelDimension,
|
||||||
|
pixelDimension,
|
||||||
|
).data,
|
||||||
|
].map((data) => {
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
r += data[i];
|
||||||
|
g += data[i + 1];
|
||||||
|
b += data[i + 2];
|
||||||
|
a += data[i + 3];
|
||||||
|
}
|
||||||
|
const dataLength = data.length / 4;
|
||||||
|
return [
|
||||||
|
r / dataLength,
|
||||||
|
g / dataLength,
|
||||||
|
b / dataLength,
|
||||||
|
a / dataLength,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const rgbColors = colors.map((color) => {
|
||||||
|
const [r, g, b, a] = lightenRGB(color);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||||
|
});
|
||||||
|
setHeaderCornerColors(rgbColors);
|
||||||
|
console.log({ colors, rgbColors });
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<header>
|
<header>
|
||||||
<AccountBlock
|
<AccountBlock
|
||||||
account={info}
|
account={info}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
avatarSize="xxxl"
|
avatarSize="xxxl"
|
||||||
external
|
external={standalone}
|
||||||
|
internal={!standalone}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main tabIndex="-1">
|
<main tabIndex="-1">
|
||||||
|
@ -174,18 +283,28 @@ function Account({ account, instance: propInstance, onClose }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p class="stats">
|
<p class="stats">
|
||||||
<Link
|
{standalone ? (
|
||||||
to={instance ? `/${instance}/a/${id}` : `/a/${id}`}
|
<span>
|
||||||
onClick={() => {
|
Posts
|
||||||
hideAllModals();
|
<br />
|
||||||
}}
|
<b title={statusesCount}>
|
||||||
>
|
{shortenNumber(statusesCount)}
|
||||||
Posts
|
</b>{' '}
|
||||||
<br />
|
</span>
|
||||||
<b title={statusesCount}>
|
) : (
|
||||||
{shortenNumber(statusesCount)}
|
<Link
|
||||||
</b>{' '}
|
to={instance ? `/${instance}/a/${id}` : `/a/${id}`}
|
||||||
</Link>
|
onClick={() => {
|
||||||
|
hideAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Posts
|
||||||
|
<br />
|
||||||
|
<b title={statusesCount}>
|
||||||
|
{shortenNumber(statusesCount)}
|
||||||
|
</b>{' '}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span>
|
<span>
|
||||||
Following
|
Following
|
||||||
<br />
|
<br />
|
||||||
|
@ -419,4 +538,20 @@ function RelatedActions({ info, instance, authenticated }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Account;
|
// Apply more alpha if high luminence
|
||||||
|
function lightenRGB([r, g, b]) {
|
||||||
|
const luminence = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
console.log('luminence', luminence);
|
||||||
|
let alpha;
|
||||||
|
if (luminence >= 220) {
|
||||||
|
alpha = 1;
|
||||||
|
} else if (luminence <= 50) {
|
||||||
|
alpha = 0.1;
|
||||||
|
} else {
|
||||||
|
alpha = luminence / 255;
|
||||||
|
}
|
||||||
|
alpha = Math.min(1, alpha);
|
||||||
|
return [r, g, b, alpha];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountInfo;
|
66
src/components/account-sheet.jsx
Normal file
66
src/components/account-sheet.jsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import states from '../utils/states';
|
||||||
|
|
||||||
|
import AccountInfo from './account-info';
|
||||||
|
|
||||||
|
function AccountSheet({ account, instance: propInstance, onClose }) {
|
||||||
|
const { masto, instance, authenticated } = api({ instance: propInstance });
|
||||||
|
const isString = typeof account === 'string';
|
||||||
|
|
||||||
|
const escRef = useHotkeys('esc', onClose, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isString) {
|
||||||
|
states.accounts[`${account.id}@${instance}`] = account;
|
||||||
|
}
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={escRef}
|
||||||
|
class="sheet"
|
||||||
|
onClick={(e) => {
|
||||||
|
const accountBlock = e.target.closest('.account-block');
|
||||||
|
if (accountBlock) {
|
||||||
|
onClose({
|
||||||
|
destination: 'account-statuses',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountInfo
|
||||||
|
instance={instance}
|
||||||
|
authenticated={authenticated}
|
||||||
|
account={account}
|
||||||
|
fetchAccount={async () => {
|
||||||
|
if (isString) {
|
||||||
|
try {
|
||||||
|
const info = await masto.v1.accounts.lookup({
|
||||||
|
acct: account,
|
||||||
|
skip_webfinger: false,
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
} catch (e) {
|
||||||
|
const result = await masto.v2.search({
|
||||||
|
q: account,
|
||||||
|
type: 'accounts',
|
||||||
|
limit: 1,
|
||||||
|
resolve: authenticated,
|
||||||
|
});
|
||||||
|
if (result.accounts.length) {
|
||||||
|
return result.accounts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountSheet;
|
|
@ -1,91 +0,0 @@
|
||||||
#account-container.skeleton {
|
|
||||||
color: var(--outline-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container .note {
|
|
||||||
font-size: 95%;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
#account-container .note:not(:has(p)):not(:empty) {
|
|
||||||
/* Some notes don't have <p> tags, so we need to add some padding */
|
|
||||||
padding: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container .stats {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-around;
|
|
||||||
gap: 16px;
|
|
||||||
opacity: 0.75;
|
|
||||||
font-size: 90%;
|
|
||||||
background-color: var(--bg-faded-color);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
#account-container .stats > * {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#account-container .stats a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container .actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: space-between;
|
|
||||||
min-height: 2.5em;
|
|
||||||
}
|
|
||||||
#account-container .actions button {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container .profile-metadata {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
#account-container .profile-field {
|
|
||||||
min-width: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
font-size: 90%;
|
|
||||||
background-color: var(--bg-faded-color);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
filter: saturate(0.75);
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container :is(.note, .profile-field) .invisible {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#account-container :is(.note, .profile-field) .ellipsis::after {
|
|
||||||
content: '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container .profile-field b {
|
|
||||||
font-size: 90%;
|
|
||||||
color: var(--text-insignificant-color);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
#account-container .profile-field b .icon {
|
|
||||||
color: var(--green-color);
|
|
||||||
}
|
|
||||||
#account-container .profile-field p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-container .common-followers {
|
|
||||||
border-top: 1px solid var(--outline-color);
|
|
||||||
border-bottom: 1px solid var(--outline-color);
|
|
||||||
padding: 8px 0;
|
|
||||||
font-size: 90%;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-insignificant-color);
|
|
||||||
}
|
|
|
@ -9,6 +9,9 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
.avatar.has-alpha {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar img {
|
.avatar img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -16,3 +19,9 @@
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background-color: var(--img-bg-color);
|
background-color: var(--img-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar[data-loaded],
|
||||||
|
.avatar[data-loaded] img {
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import './avatar.css';
|
import './avatar.css';
|
||||||
|
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
const SIZES = {
|
const SIZES = {
|
||||||
s: 16,
|
s: 16,
|
||||||
m: 20,
|
m: 20,
|
||||||
|
@ -9,11 +11,15 @@ const SIZES = {
|
||||||
xxxl: 64,
|
xxxl: 64,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const alphaCache = {};
|
||||||
|
|
||||||
function Avatar({ url, size, alt = '', ...props }) {
|
function Avatar({ url, size, alt = '', ...props }) {
|
||||||
size = SIZES[size] || size || SIZES.m;
|
size = SIZES[size] || size || SIZES.m;
|
||||||
|
const avatarRef = useRef();
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
class="avatar"
|
ref={avatarRef}
|
||||||
|
class={`avatar ${alphaCache[url] ? 'has-alpha' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
@ -22,7 +28,50 @@ function Avatar({ url, size, alt = '', ...props }) {
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{!!url && (
|
{!!url && (
|
||||||
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
<img
|
||||||
|
src={url}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
crossOrigin={alphaCache[url] === undefined ? 'anonymous' : undefined}
|
||||||
|
onError={(e) => {
|
||||||
|
if (e.target.crossOrigin) {
|
||||||
|
e.target.crossOrigin = null;
|
||||||
|
e.target.src = url;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onLoad={(e) => {
|
||||||
|
if (avatarRef.current) avatarRef.current.dataset.loaded = true;
|
||||||
|
try {
|
||||||
|
// Check if image has alpha channel
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = e.target.width;
|
||||||
|
canvas.height = e.target.height;
|
||||||
|
ctx.drawImage(e.target, 0, 0);
|
||||||
|
const allPixels = ctx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
);
|
||||||
|
// At least 10% of pixels have alpha <= 128
|
||||||
|
const hasAlpha =
|
||||||
|
allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128)
|
||||||
|
.length /
|
||||||
|
(allPixels.data.length / 4) >
|
||||||
|
0.1;
|
||||||
|
if (hasAlpha) {
|
||||||
|
// console.log('hasAlpha', hasAlpha, allPixels.data);
|
||||||
|
avatarRef.current.classList.add('has-alpha');
|
||||||
|
alphaCache[url] = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
@ -31,6 +31,10 @@
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
#compose-container textarea:hover {
|
||||||
|
border-color: var(--divider-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 40em) {
|
@media (min-width: 40em) {
|
||||||
|
@ -51,7 +55,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#compose-container .status-preview {
|
#compose-container .status-preview {
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
margin: 0 12px;
|
margin: 0 12px;
|
||||||
|
@ -59,6 +63,7 @@
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
animation: appear-up 1s ease-in-out;
|
animation: appear-up 1s ease-in-out;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
|
||||||
}
|
}
|
||||||
#compose-container .status-preview :is(.hashtag, .time) {
|
#compose-container .status-preview :is(.hashtag, .time) {
|
||||||
/* Prevent hashtags from being clickable */
|
/* Prevent hashtags from being clickable */
|
||||||
|
@ -87,7 +92,7 @@
|
||||||
transparent,
|
transparent,
|
||||||
var(--bg-faded-color)
|
var(--bg-faded-color)
|
||||||
); */
|
); */
|
||||||
border-top: 1px solid var(--outline-color);
|
border-top: var(--hairline-width) solid var(--outline-color);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||||
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||||
|
@ -105,14 +110,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#compose-container form {
|
#compose-container form {
|
||||||
border-radius: 8px;
|
border-radius: 16px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background-image: linear-gradient(var(--bg-color) 75%, transparent);
|
background-color: var(--bg-blur-color);
|
||||||
|
/* background-image: linear-gradient(var(--bg-color) 85%, transparent); */
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
|
--drop-shadow: 0 3px 6px -3px var(--drop-shadow-color);
|
||||||
|
box-shadow: var(--drop-shadow);
|
||||||
}
|
}
|
||||||
#compose-container .status-preview ~ form {
|
#compose-container .status-preview ~ form {
|
||||||
box-shadow: 0 -12px 12px -12px var(--divider-color);
|
box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#compose-container .toolbar {
|
#compose-container .toolbar {
|
||||||
|
@ -131,8 +139,8 @@
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button {
|
#compose-container .toolbar-button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: var(--text-color);
|
color: var(--link-color);
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-blur-color);
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-height: 2.4em;
|
min-height: 2.4em;
|
||||||
|
@ -150,9 +158,10 @@
|
||||||
cursor: inherit;
|
cursor: inherit;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button:has([disabled]) {
|
#compose-container .toolbar-button:has([disabled]),
|
||||||
|
#compose-container .toolbar-button[disabled] {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: var(--bg-faded-color);
|
background-color: transparent;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
#compose-container
|
#compose-container
|
||||||
|
@ -186,9 +195,14 @@
|
||||||
) {
|
) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: none;
|
filter: none;
|
||||||
border-color: var(--divider-color);
|
background-color: var(--bg-color);
|
||||||
|
border-color: var(--link-faded-color);
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
#compose-container .toolbar-button:not(:disabled).highlight {
|
||||||
|
border-color: var(--link-color);
|
||||||
|
box-shadow: inset 0 0 8px var(--link-faded-color);
|
||||||
|
}
|
||||||
#compose-container .toolbar-button:not(:disabled):active {
|
#compose-container .toolbar-button:not(:disabled):active {
|
||||||
filter: brightness(0.8);
|
filter: brightness(0.8);
|
||||||
}
|
}
|
||||||
|
@ -430,6 +444,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
#compose-container button[type='submit'] {
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#media-sheet main {
|
#media-sheet main {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -348,12 +348,24 @@ function Compose({
|
||||||
};
|
};
|
||||||
useEffect(updateCharCount, []);
|
useEffect(updateCharCount, []);
|
||||||
|
|
||||||
|
const escDownRef = useRef(false);
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'esc',
|
'esc',
|
||||||
() => {
|
() => {
|
||||||
if (!standalone && confirmClose()) {
|
escDownRef.current = true;
|
||||||
|
// This won't be true if this event is already handled and not propagated 🤞
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
useHotkeys(
|
||||||
|
'esc',
|
||||||
|
() => {
|
||||||
|
if (!standalone && escDownRef.current && confirmClose()) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
escDownRef.current = false;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableOnFormTags: true,
|
enableOnFormTags: true,
|
||||||
|
@ -490,7 +502,7 @@ function Compose({
|
||||||
{currentAccountInfo?.avatarStatic && (
|
{currentAccountInfo?.avatarStatic && (
|
||||||
<Avatar
|
<Avatar
|
||||||
url={currentAccountInfo.avatarStatic}
|
url={currentAccountInfo.avatarStatic}
|
||||||
size="l"
|
size="xl"
|
||||||
alt={currentAccountInfo.username}
|
alt={currentAccountInfo.username}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -687,6 +699,17 @@ function Compose({
|
||||||
}
|
}
|
||||||
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
|
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
|
||||||
|
|
||||||
|
if (mediaAttachments.length > 0) {
|
||||||
|
// If there are media attachments, check if they have no descriptions
|
||||||
|
const hasNoDescriptions = mediaAttachments.some(
|
||||||
|
(media) => !media.description?.trim?.(),
|
||||||
|
);
|
||||||
|
if (hasNoDescriptions) {
|
||||||
|
const yes = confirm('Some media have no descriptions. Continue?');
|
||||||
|
if (!yes) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Post-cleanup
|
// Post-cleanup
|
||||||
spoilerText = (sensitive && spoilerText) || undefined;
|
spoilerText = (sensitive && spoilerText) || undefined;
|
||||||
status = status === '' ? undefined : status;
|
status = status === '' ? undefined : status;
|
||||||
|
@ -819,7 +842,7 @@ function Compose({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toolbar-button"
|
class={`toolbar-button ${sensitive ? 'highlight' : ''}`}
|
||||||
title="Content warning or sensitive media"
|
title="Content warning or sensitive media"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
@ -842,7 +865,7 @@ function Compose({
|
||||||
<label
|
<label
|
||||||
class={`toolbar-button ${
|
class={`toolbar-button ${
|
||||||
visibility !== 'public' && !sensitive ? 'show-field' : ''
|
visibility !== 'public' && !sensitive ? 'show-field' : ''
|
||||||
}`}
|
} ${visibility !== 'public' ? 'highlight' : ''}`}
|
||||||
title={`Visibility: ${visibility}`}
|
title={`Visibility: ${visibility}`}
|
||||||
>
|
>
|
||||||
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
|
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
|
||||||
|
|
|
@ -63,11 +63,20 @@ const ICONS = {
|
||||||
share: 'mingcute:share-2-line',
|
share: 'mingcute:share-2-line',
|
||||||
sparkles: 'mingcute:sparkles-line',
|
sparkles: 'mingcute:sparkles-line',
|
||||||
exit: 'mingcute:exit-line',
|
exit: 'mingcute:exit-line',
|
||||||
|
translate: 'mingcute:translate-line',
|
||||||
|
play: 'mingcute:play-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
|
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
|
||||||
|
|
||||||
function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
function Icon({
|
||||||
|
icon,
|
||||||
|
size = 'm',
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
class: className = '',
|
||||||
|
style = {},
|
||||||
|
}) {
|
||||||
if (!icon) return null;
|
if (!icon) return null;
|
||||||
|
|
||||||
const iconSize = SIZES[size];
|
const iconSize = SIZES[size];
|
||||||
|
@ -96,6 +105,7 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{iconData && (
|
{iconData && (
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
animation: appear 0.3s ease-in-out 1s both;
|
animation: appear 0.3s ease-in-out 1s both;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
|
vertical-align: baseline !important;
|
||||||
}
|
}
|
||||||
.loader-container.abrupt {
|
.loader-container.abrupt {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
@ -6,6 +7,7 @@ import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import Media from './media';
|
import Media from './media';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
|
import TranslationBlock from './translation-block';
|
||||||
|
|
||||||
function MediaModal({
|
function MediaModal({
|
||||||
mediaAttachments,
|
mediaAttachments,
|
||||||
|
@ -234,49 +236,54 @@ function MediaModal({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="sheet">
|
<MediaAltModal alt={showMediaAlt} />
|
||||||
<header>
|
|
||||||
<h2>Media description</h2>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showMediaAlt}
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
{!!showMediaAlt && (
|
|
||||||
<Modal
|
|
||||||
class="light"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
setShowMediaAlt(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="sheet">
|
|
||||||
<header>
|
|
||||||
<h2>Media description</h2>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showMediaAlt}
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaAltModal({ alt }) {
|
||||||
|
const [forceTranslate, setForceTranslate] = useState(false);
|
||||||
|
return (
|
||||||
|
<div class="sheet">
|
||||||
|
<header class="header-grid">
|
||||||
|
<h2>Media description</h2>
|
||||||
|
<div class="header-side">
|
||||||
|
<Menu
|
||||||
|
align="end"
|
||||||
|
menuButton={
|
||||||
|
<button type="button" class="plain4">
|
||||||
|
<Icon icon="more" alt="More" size="xl" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
disabled={forceTranslate}
|
||||||
|
onClick={() => {
|
||||||
|
setForceTranslate(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="translate" />
|
||||||
|
<span>Translate</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alt}
|
||||||
|
</p>
|
||||||
|
{forceTranslate && (
|
||||||
|
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default MediaModal;
|
export default MediaModal;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
import { useRef } from 'preact/hooks';
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Icon from './icon';
|
||||||
import { formatDuration } from './status';
|
import { formatDuration } from './status';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -74,6 +75,14 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||||
backgroundPosition: focalBackgroundPosition || 'center',
|
backgroundPosition: focalBackgroundPosition || 'center',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onDblClick={() => {
|
||||||
|
// Open original image in new tab
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}}
|
||||||
|
onLoad={(e) => {
|
||||||
|
// Hide background image after image loads
|
||||||
|
e.target.parentElement.style.backgroundImage = 'none';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -161,13 +170,18 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||||
muted
|
muted
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<>
|
||||||
src={previewUrl}
|
<img
|
||||||
alt={description}
|
src={previewUrl}
|
||||||
width={width}
|
alt={description}
|
||||||
height={height}
|
width={width}
|
||||||
loading="lazy"
|
height={height}
|
||||||
/>
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="media-play">
|
||||||
|
<Icon icon="play" size="xxl" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
import { getCurrentAccount } from '../utils/store-utils';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import MenuLink from './MenuLink';
|
import MenuLink from './MenuLink';
|
||||||
|
@ -10,6 +11,7 @@ import MenuLink from './MenuLink';
|
||||||
function NavMenu(props) {
|
function NavMenu(props) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const { instance, authenticated } = api();
|
const { instance, authenticated } = api();
|
||||||
|
const currentAccount = getCurrentAccount();
|
||||||
|
|
||||||
// Home = Following
|
// Home = Following
|
||||||
// But when in multi-column mode, Home becomes columns of anything
|
// But when in multi-column mode, Home becomes columns of anything
|
||||||
|
@ -102,6 +104,18 @@ function NavMenu(props) {
|
||||||
{authenticated && (
|
{authenticated && (
|
||||||
<>
|
<>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
{currentAccount?.info?.id && (
|
||||||
|
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
|
||||||
|
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||||
|
</MenuLink>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
states.showAccounts = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="group" size="l" /> <span>Accounts…</span>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showShortcutsSettings = true;
|
states.showShortcutsSettings = true;
|
||||||
|
|
|
@ -52,7 +52,7 @@ function NameText({
|
||||||
>
|
>
|
||||||
{showAvatar && (
|
{showAvatar && (
|
||||||
<>
|
<>
|
||||||
<Avatar url={avatar} />{' '}
|
<Avatar url={avatarStatic || avatar} />{' '}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{displayName && !short ? (
|
{displayName && !short ? (
|
||||||
|
|
|
@ -33,7 +33,55 @@
|
||||||
#shortcuts-settings-container .shortcuts-view-mode {
|
#shortcuts-settings-container .shortcuts-view-mode {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label {
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-bottom-left-radius: 16px;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label:last-child {
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
border-bottom-right-radius: 16px;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label img {
|
||||||
|
max-height: 64px;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label span {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label input ~ * {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--link-color);
|
||||||
|
}
|
||||||
|
#shortcuts-settings-container
|
||||||
|
.shortcuts-view-mode
|
||||||
|
label
|
||||||
|
input:is(:hover, :active, :checked)
|
||||||
|
~ * {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#shortcuts-settings-container summary {
|
#shortcuts-settings-container summary {
|
||||||
|
|
|
@ -4,6 +4,9 @@ import mem from 'mem';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import floatingButtonUrl from '../assets/floating-button.svg';
|
||||||
|
import multiColumnUrl from '../assets/multi-column.svg';
|
||||||
|
import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
|
@ -208,9 +211,40 @@ function ShortcutsSettings() {
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<p>
|
<p>
|
||||||
<label class="shortcuts-view-mode">
|
Specify a list of shortcuts that'll appear as:
|
||||||
Specify a list of shortcuts that'll appear as:
|
<div class="shortcuts-view-mode">
|
||||||
<select
|
{[
|
||||||
|
{
|
||||||
|
value: 'float-button',
|
||||||
|
label: 'Floating button',
|
||||||
|
imgURL: floatingButtonUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tab-menu-bar',
|
||||||
|
label: 'Tab/Menu bar',
|
||||||
|
imgURL: tabMenuBarUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'multi-column',
|
||||||
|
label: 'Multi-column',
|
||||||
|
imgURL: multiColumnUrl,
|
||||||
|
},
|
||||||
|
].map(({ value, label, imgURL }) => (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="shortcuts-view-mode"
|
||||||
|
value={value}
|
||||||
|
checked={snapStates.settings.shortcutsViewMode === value}
|
||||||
|
onChange={(e) => {
|
||||||
|
states.settings.shortcutsViewMode = e.target.value;
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<img src={imgURL} alt="" /> <span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* <select
|
||||||
value={snapStates.settings.shortcutsViewMode || 'float-button'}
|
value={snapStates.settings.shortcutsViewMode || 'float-button'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
states.settings.shortcutsViewMode = e.target.value;
|
states.settings.shortcutsViewMode = e.target.value;
|
||||||
|
@ -219,8 +253,7 @@ function ShortcutsSettings() {
|
||||||
<option value="float-button">Floating button</option>
|
<option value="float-button">Floating button</option>
|
||||||
<option value="multi-column">Multi-column</option>
|
<option value="multi-column">Multi-column</option>
|
||||||
<option value="tab-menu-bar">Tab/Menu bar </option>
|
<option value="tab-menu-bar">Tab/Menu bar </option>
|
||||||
</select>
|
</select> */}
|
||||||
</label>
|
|
||||||
</p>
|
</p>
|
||||||
{/* <p>
|
{/* <p>
|
||||||
<details>
|
<details>
|
||||||
|
|
|
@ -15,7 +15,9 @@
|
||||||
#shortcuts-button .icon {
|
#shortcuts-button .icon {
|
||||||
transform: translateY(2px); /* Balance the icon's vertical alignment */
|
transform: translateY(2px); /* Balance the icon's vertical alignment */
|
||||||
}
|
}
|
||||||
#app:has(header[hidden]) #shortcuts-button,
|
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden])
|
||||||
|
#shortcuts-button,
|
||||||
|
#app:has(#home-page ~ .deck-container header[hidden]) #shortcuts-button,
|
||||||
#shortcuts-button[hidden] {
|
#shortcuts-button[hidden] {
|
||||||
transform: translateY(200%);
|
transform: translateY(200%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
@ -39,7 +41,11 @@
|
||||||
top: max(16px, env(safe-area-inset-top));
|
top: max(16px, env(safe-area-inset-top));
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
}
|
}
|
||||||
#app:has(header[hidden]) #shortcuts-button,
|
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(
|
||||||
|
header[hidden]
|
||||||
|
)
|
||||||
|
#shortcuts-button,
|
||||||
|
#app:has(#home-page ~ .deck-container header[hidden]) #shortcuts-button,
|
||||||
#shortcuts-button[hidden] {
|
#shortcuts-button[hidden] {
|
||||||
transform: translateY(-200%);
|
transform: translateY(-200%);
|
||||||
}
|
}
|
||||||
|
@ -114,7 +120,10 @@
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#app:has(header[hidden]) #shortcuts .tab-bar,
|
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden])
|
||||||
|
#shortcuts
|
||||||
|
.tab-bar,
|
||||||
|
#app:has(#home-page ~ .deck-container header[hidden]) #shortcuts .tab-bar,
|
||||||
shortcuts .tab-bar[hidden] {
|
shortcuts .tab-bar[hidden] {
|
||||||
transform: translateY(200%);
|
transform: translateY(200%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
@ -163,7 +172,12 @@ shortcuts .tab-bar[hidden] {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
#app:has(header[hidden]) #shortcuts .tab-bar,
|
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(
|
||||||
|
header[hidden]
|
||||||
|
)
|
||||||
|
#shortcuts
|
||||||
|
.tab-bar,
|
||||||
|
#app:has(#home-page ~ .deck-container header[hidden]) #shortcuts .tab-bar,
|
||||||
shortcuts .tab-bar[hidden] {
|
shortcuts .tab-bar[hidden] {
|
||||||
transform: translateY(-150%);
|
transform: translateY(-150%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
|
@ -251,6 +251,9 @@
|
||||||
filter: none;
|
filter: none;
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
}
|
}
|
||||||
|
.status .content a:not(.mention):not(:has(span)) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-deck .status .content {
|
.timeline-deck .status .content {
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
|
@ -317,7 +320,7 @@
|
||||||
}
|
}
|
||||||
.status.large .content {
|
.status.large .content {
|
||||||
font-size: 150%;
|
font-size: 150%;
|
||||||
font-size: calc(100% + 50% / var(--content-text-weight));
|
font-size: min(calc(100% + 50% / var(--content-text-weight)), 150%);
|
||||||
}
|
}
|
||||||
.status.large .poll,
|
.status.large .poll,
|
||||||
.status.large .actions {
|
.status.large .actions {
|
||||||
|
@ -426,20 +429,6 @@
|
||||||
.status .media {
|
.status .media {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@keyframes position-object {
|
|
||||||
0% {
|
|
||||||
object-position: 50% 50%;
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
object-position: 0% 0%;
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
object-position: 100% 100%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
object-position: 50% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.status .media img:is(:hover, :focus),
|
.status .media img:is(:hover, :focus),
|
||||||
a:focus-visible .status .media img {
|
a:focus-visible .status .media img {
|
||||||
animation: position-object 5s ease-in-out 1s 5;
|
animation: position-object 5s ease-in-out 1s 5;
|
||||||
|
@ -456,14 +445,11 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
.status :is(.media-video, .media-audio)[data-formatted-duration]:before {
|
.status :is(.media-video, .media-audio)[data-formatted-duration] .media-play {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
content: '⏵';
|
|
||||||
width: 70px;
|
width: 70px;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
font-size: 50px;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-indent: 3px;
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
@ -476,7 +462,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
||||||
border-radius: 70px;
|
border-radius: 70px;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
.status :is(.media-video, .media-audio)[data-formatted-duration]:hover:before {
|
.status
|
||||||
|
:is(.media-video, .media-audio)[data-formatted-duration]:hover
|
||||||
|
.media-play {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import pThrottle from 'p-throttle';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import 'swiped-events';
|
import 'swiped-events';
|
||||||
|
import { useLongPress } from 'use-long-press';
|
||||||
import useResizeObserver from 'use-resize-observer';
|
import useResizeObserver from 'use-resize-observer';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -20,6 +21,7 @@ import Modal from '../components/modal';
|
||||||
import NameText from '../components/name-text';
|
import NameText from '../components/name-text';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import enhanceContent from '../utils/enhance-content';
|
import enhanceContent from '../utils/enhance-content';
|
||||||
|
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||||
import handleContentLinks from '../utils/handle-content-links';
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
import htmlContentLength from '../utils/html-content-length';
|
import htmlContentLength from '../utils/html-content-length';
|
||||||
import niceDateTime from '../utils/nice-date-time';
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
|
@ -35,6 +37,7 @@ import Link from './link';
|
||||||
import Media from './media';
|
import Media from './media';
|
||||||
import MenuLink from './MenuLink';
|
import MenuLink from './MenuLink';
|
||||||
import RelativeTime from './relative-time';
|
import RelativeTime from './relative-time';
|
||||||
|
import TranslationBlock from './translation-block';
|
||||||
|
|
||||||
const throttle = pThrottle({
|
const throttle = pThrottle({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
@ -66,6 +69,7 @@ function Status({
|
||||||
skeleton,
|
skeleton,
|
||||||
readOnly,
|
readOnly,
|
||||||
contentTextWeight,
|
contentTextWeight,
|
||||||
|
enableTranslate,
|
||||||
}) {
|
}) {
|
||||||
if (skeleton) {
|
if (skeleton) {
|
||||||
return (
|
return (
|
||||||
|
@ -194,6 +198,10 @@ function Status({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [forceTranslate, setForceTranslate] = useState(false);
|
||||||
|
const targetLanguage = getTranslateTargetLanguage(true);
|
||||||
|
if (!snapStates.settings.contentTranslation) enableTranslate = false;
|
||||||
|
|
||||||
const [showEdited, setShowEdited] = useState(false);
|
const [showEdited, setShowEdited] = useState(false);
|
||||||
|
|
||||||
const spoilerContentRef = useRef(null);
|
const spoilerContentRef = useRef(null);
|
||||||
|
@ -229,14 +237,24 @@ function Status({
|
||||||
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`;
|
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`;
|
||||||
|
|
||||||
const textWeight = () =>
|
const textWeight = () =>
|
||||||
Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1;
|
Math.max(
|
||||||
|
Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
const createdDateText = niceDateTime(createdAtDate);
|
const createdDateText = niceDateTime(createdAtDate);
|
||||||
const editedDateText = editedAt && niceDateTime(editedAtDate);
|
const editedDateText = editedAt && niceDateTime(editedAtDate);
|
||||||
|
|
||||||
const isSizeLarge = size === 'l';
|
const isSizeLarge = size === 'l';
|
||||||
// TODO: if visibility = private, only can boost own statuses
|
// Can boost if:
|
||||||
const canBoost = authenticated && visibility !== 'direct';
|
// - authenticated AND
|
||||||
|
// - visibility != direct OR
|
||||||
|
// - visibility = private AND isSelf
|
||||||
|
let canBoost =
|
||||||
|
authenticated && visibility !== 'direct' && visibility !== 'private';
|
||||||
|
if (visibility === 'private' && isSelf) {
|
||||||
|
canBoost = true;
|
||||||
|
}
|
||||||
|
|
||||||
const replyStatus = () => {
|
const replyStatus = () => {
|
||||||
if (!sameInstance || !authenticated) {
|
if (!sameInstance || !authenticated) {
|
||||||
|
@ -253,7 +271,15 @@ function Status({
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!reblogged) {
|
if (!reblogged) {
|
||||||
const yes = confirm('Boost this post?');
|
// Check if media has no descriptions
|
||||||
|
const hasNoDescriptions = mediaAttachments.some(
|
||||||
|
(attachment) => !attachment.description?.trim?.(),
|
||||||
|
);
|
||||||
|
let confirmText = 'Boost this post?';
|
||||||
|
if (hasNoDescriptions) {
|
||||||
|
confirmText += '\n\n⚠️ Some media have no descriptions.';
|
||||||
|
}
|
||||||
|
const yes = confirm(confirmText);
|
||||||
if (!yes) {
|
if (!yes) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -362,7 +388,7 @@ function Status({
|
||||||
</MenuHeader>
|
</MenuHeader>
|
||||||
<MenuLink to={instance ? `/${instance}/s/${id}` : `/s/${id}`}>
|
<MenuLink to={instance ? `/${instance}/s/${id}` : `/s/${id}`}>
|
||||||
<Icon icon="arrow-right" />
|
<Icon icon="arrow-right" />
|
||||||
View post and replies
|
<span>View post by @{username || acct}</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -381,7 +407,7 @@ function Status({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
|
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
|
||||||
{!isSizeLarge && (
|
{!isSizeLarge && sameInstance && (
|
||||||
<>
|
<>
|
||||||
<MenuItem onClick={replyStatus}>
|
<MenuItem onClick={replyStatus}>
|
||||||
<Icon icon="reply" />
|
<Icon icon="reply" />
|
||||||
|
@ -397,7 +423,12 @@ function Status({
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="rocket" />
|
<Icon
|
||||||
|
icon="rocket"
|
||||||
|
style={{
|
||||||
|
color: reblogged && 'var(--reblog-color)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
|
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
@ -410,7 +441,12 @@ function Status({
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="heart" />
|
<Icon
|
||||||
|
icon="heart"
|
||||||
|
style={{
|
||||||
|
color: favourited && 'var(--favourite-color)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
|
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
@ -422,51 +458,69 @@ function Status({
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="bookmark" />
|
<Icon
|
||||||
|
icon="bookmark"
|
||||||
|
style={{
|
||||||
|
color: bookmarked && 'var(--favourite-color)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
|
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuDivider />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{enableTranslate && (
|
||||||
|
<MenuItem
|
||||||
|
disabled={forceTranslate}
|
||||||
|
onClick={() => {
|
||||||
|
setForceTranslate(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="translate" />
|
||||||
|
<span>Translate</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />}
|
||||||
<MenuItem href={url} target="_blank">
|
<MenuItem href={url} target="_blank">
|
||||||
<Icon icon="external" />
|
<Icon icon="external" />
|
||||||
<span>Open link to post</span>
|
<small class="menu-double-lines">{nicePostURL(url)}</small>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<div class="menu-horizontal">
|
||||||
onClick={() => {
|
<MenuItem
|
||||||
// Copy url to clipboard
|
onClick={() => {
|
||||||
try {
|
// Copy url to clipboard
|
||||||
navigator.clipboard.writeText(url);
|
try {
|
||||||
showToast('Link copied');
|
navigator.clipboard.writeText(url);
|
||||||
} catch (e) {
|
showToast('Link copied');
|
||||||
console.error(e);
|
} catch (e) {
|
||||||
showToast('Unable to copy link');
|
console.error(e);
|
||||||
}
|
showToast('Unable to copy link');
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<Icon icon="link" />
|
>
|
||||||
<span>Copy link to post</span>
|
<Icon icon="link" />
|
||||||
</MenuItem>
|
<span>Copy</span>
|
||||||
{navigator?.share &&
|
</MenuItem>
|
||||||
navigator?.canShare?.({
|
{navigator?.share &&
|
||||||
url,
|
navigator?.canShare?.({
|
||||||
}) && (
|
url,
|
||||||
<MenuItem
|
}) && (
|
||||||
onClick={() => {
|
<MenuItem
|
||||||
try {
|
onClick={() => {
|
||||||
navigator.share({
|
try {
|
||||||
url,
|
navigator.share({
|
||||||
});
|
url,
|
||||||
} catch (e) {
|
});
|
||||||
console.error(e);
|
} catch (e) {
|
||||||
alert("Sharing doesn't seem to work.");
|
console.error(e);
|
||||||
}
|
alert("Sharing doesn't seem to work.");
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<Icon icon="share" />
|
>
|
||||||
<span>Share…</span>
|
<Icon icon="share" />
|
||||||
</MenuItem>
|
<span>Share…</span>
|
||||||
)}
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<>
|
<>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
@ -485,11 +539,27 @@ function Status({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const contextMenuRef = useRef();
|
||||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||||
const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({
|
const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
});
|
});
|
||||||
|
const bindLongPress = useLongPress(
|
||||||
|
(e) => {
|
||||||
|
const { clientX, clientY } = e.touches?.[0] || e;
|
||||||
|
setContextMenuAnchorPoint({
|
||||||
|
x: clientX,
|
||||||
|
y: clientY,
|
||||||
|
});
|
||||||
|
setIsContextMenuOpen(true);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureEvent: true,
|
||||||
|
detect: 'touch',
|
||||||
|
cancelOnMovement: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
@ -508,6 +578,9 @@ function Status({
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
if (size === 'l') return;
|
if (size === 'l') return;
|
||||||
if (e.metaKey) return;
|
if (e.metaKey) return;
|
||||||
|
// console.log('context menu', e);
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenuAnchorPoint({
|
setContextMenuAnchorPoint({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
|
@ -515,9 +588,11 @@ function Status({
|
||||||
});
|
});
|
||||||
setIsContextMenuOpen(true);
|
setIsContextMenuOpen(true);
|
||||||
}}
|
}}
|
||||||
|
{...bindLongPress()}
|
||||||
>
|
>
|
||||||
{size !== 'l' && (
|
{size !== 'l' && (
|
||||||
<ControlledMenu
|
<ControlledMenu
|
||||||
|
ref={contextMenuRef}
|
||||||
state={isContextMenuOpen ? 'open' : undefined}
|
state={isContextMenuOpen ? 'open' : undefined}
|
||||||
anchorPoint={contextMenuAnchorPoint}
|
anchorPoint={contextMenuAnchorPoint}
|
||||||
direction="right"
|
direction="right"
|
||||||
|
@ -530,9 +605,12 @@ function Status({
|
||||||
// Higher than the backdrop
|
// Higher than the backdrop
|
||||||
zIndex: 1001,
|
zIndex: 1001,
|
||||||
},
|
},
|
||||||
|
onClick: () => {
|
||||||
|
contextMenuRef.current?.closeMenu?.();
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
boundingBoxPadding="8 8 8 8"
|
boundingBoxPadding={safeBoundingBoxPadding()}
|
||||||
unmountOnClose
|
unmountOnClose
|
||||||
>
|
>
|
||||||
{StatusMenuItems}
|
{StatusMenuItems}
|
||||||
|
@ -561,7 +639,7 @@ function Status({
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar url={avatarStatic} size="xxl" />
|
<Avatar url={avatarStatic || avatar} size="xxl" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -767,6 +845,25 @@ function Status({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{((enableTranslate &&
|
||||||
|
!!content.trim() &&
|
||||||
|
language &&
|
||||||
|
language !== targetLanguage) ||
|
||||||
|
forceTranslate) && (
|
||||||
|
<TranslationBlock
|
||||||
|
forceTranslate={forceTranslate}
|
||||||
|
sourceLanguage={language}
|
||||||
|
text={
|
||||||
|
(spoilerText ? `${spoilerText}\n\n` : '') +
|
||||||
|
getHTMLText(content) +
|
||||||
|
(poll?.options?.length
|
||||||
|
? `\n\nPoll:\n${poll.options
|
||||||
|
.map((option) => `- ${option.title}`)
|
||||||
|
.join('\n')}`
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
||||||
<button
|
<button
|
||||||
class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||||
|
@ -1295,7 +1392,14 @@ function EditedAtModal({
|
||||||
return (
|
return (
|
||||||
<li key={createdAt} class="history-item">
|
<li key={createdAt} class="history-item">
|
||||||
<h3>
|
<h3>
|
||||||
<time>{niceDateTime(createdAtDate)}</time>
|
<time>
|
||||||
|
{niceDateTime(createdAtDate, {
|
||||||
|
formatOpts: {
|
||||||
|
weekday: 'short',
|
||||||
|
second: 'numeric',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
</h3>
|
</h3>
|
||||||
<Status
|
<Status
|
||||||
status={status}
|
status={status}
|
||||||
|
@ -1468,6 +1572,62 @@ function _unfurlMastodonLink(instance, url) {
|
||||||
return Promise.any([remoteInstanceFetch, mastoSearchFetch]);
|
return Promise.any([remoteInstanceFetch, mastoSearchFetch]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nicePostURL(url) {
|
||||||
|
if (!url) return;
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const { host, pathname } = urlObj;
|
||||||
|
const path = pathname.replace(/\/$/, '');
|
||||||
|
// split only first slash
|
||||||
|
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{host}
|
||||||
|
{username ? (
|
||||||
|
<>
|
||||||
|
/{username}
|
||||||
|
<wbr />
|
||||||
|
<span class="more-insignificant">/{restPath}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span class="more-insignificant">{path}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
|
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
function getHTMLText(html) {
|
||||||
|
if (!html) return 0;
|
||||||
|
div.innerHTML = html
|
||||||
|
.replace(/<\/p>/g, '</p>\n\n')
|
||||||
|
.replace(/<\/li>/g, '</li>\n');
|
||||||
|
div.querySelectorAll('br').forEach((br) => {
|
||||||
|
br.replaceWith('\n');
|
||||||
|
});
|
||||||
|
return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const defaultBoundingBoxPadding = 8;
|
||||||
|
function safeBoundingBoxPadding() {
|
||||||
|
// Get safe area inset variables from root
|
||||||
|
const style = getComputedStyle(root);
|
||||||
|
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
|
||||||
|
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
|
||||||
|
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
|
||||||
|
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
|
||||||
|
const str = [
|
||||||
|
safeAreaInsetTop,
|
||||||
|
safeAreaInsetRight,
|
||||||
|
safeAreaInsetBottom,
|
||||||
|
safeAreaInsetLeft,
|
||||||
|
]
|
||||||
|
.map((v) => parseInt(v, 10) || defaultBoundingBoxPadding)
|
||||||
|
.join(' ');
|
||||||
|
// console.log(str);
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(Status);
|
export default memo(Status);
|
||||||
|
|
|
@ -27,6 +27,7 @@ function Timeline({
|
||||||
checkForUpdatesInterval = 60_000, // 1 minute
|
checkForUpdatesInterval = 60_000, // 1 minute
|
||||||
headerStart,
|
headerStart,
|
||||||
headerEnd,
|
headerEnd,
|
||||||
|
timelineStart,
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
@ -292,11 +293,12 @@ function Timeline({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
{!!timelineStart && <div class="timeline-start">{timelineStart}</div>}
|
||||||
{!!items.length ? (
|
{!!items.length ? (
|
||||||
<>
|
<>
|
||||||
<ul class="timeline">
|
<ul class="timeline">
|
||||||
{items.map((status) => {
|
{items.map((status) => {
|
||||||
const { id: statusID, reblog, items, type } = status;
|
const { id: statusID, reblog, items, type, _pinned } = status;
|
||||||
const actualStatusID = reblog?.id || statusID;
|
const actualStatusID = reblog?.id || statusID;
|
||||||
const url = instance
|
const url = instance
|
||||||
? `/${instance}/s/${actualStatusID}`
|
? `/${instance}/s/${actualStatusID}`
|
||||||
|
@ -347,7 +349,7 @@ function Timeline({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<li key={`timeline-${statusID}`}>
|
<li key={`timeline-${statusID + _pinned}`}>
|
||||||
<Link class="status-link timeline-item" to={url}>
|
<Link class="status-link timeline-item" to={url}>
|
||||||
{useItemID ? (
|
{useItemID ? (
|
||||||
<Status statusID={statusID} instance={instance} />
|
<Status statusID={statusID} instance={instance} />
|
||||||
|
|
86
src/components/translation-block.css
Normal file
86
src/components/translation-block.css
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
.status-translation-block {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 90%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.status-translation-block summary {
|
||||||
|
list-style: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.status-translation-block summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status-translation-block summary button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
.status-translation-block summary button:is(:hover, :focus) {
|
||||||
|
color: var(--text-color);
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
.status-translation-block details:not([open]) .detected {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* .status-translation-block details summary button:active, */
|
||||||
|
.status-translation-block details[open] summary button {
|
||||||
|
/* color: var(--text-color); */
|
||||||
|
/* background-color: var(--bg-faded-color); */
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to top left,
|
||||||
|
var(--bg-color) 50%,
|
||||||
|
var(--bg-faded-blur-color)
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--bg-color);
|
||||||
|
}
|
||||||
|
.status-translation-block .translated-block {
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
line-height: 1.3;
|
||||||
|
border-radius: 0 8px 8px 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom right,
|
||||||
|
var(--bg-color),
|
||||||
|
var(--bg-faded-blur-color)
|
||||||
|
);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--bg-color),
|
||||||
|
0 1px 5px -2px var(--drop-shadow-color);
|
||||||
|
text-shadow: 0 1px var(--bg-color);
|
||||||
|
}
|
||||||
|
.status-translation-block .translated-block .translation-info * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-translation-block .translated-source-select {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
color: inherit;
|
||||||
|
width: min-content;
|
||||||
|
}
|
||||||
|
.status-translation-block .translated-block output {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
.status-translation-block
|
||||||
|
.translated-block
|
||||||
|
output.translated-pronunciation-content {
|
||||||
|
opacity: 0.75;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
border-top: var(--hairline-width) solid var(--bg-color);
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
}
|
154
src/components/translation-block.jsx
Normal file
154
src/components/translation-block.jsx
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import './translation-block.css';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import sourceLanguages from '../data/lingva-source-languages';
|
||||||
|
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||||
|
import localeCode2Text from '../utils/localeCode2Text';
|
||||||
|
|
||||||
|
import Icon from './icon';
|
||||||
|
import Loader from './loader';
|
||||||
|
|
||||||
|
function TranslationBlock({
|
||||||
|
forceTranslate,
|
||||||
|
sourceLanguage,
|
||||||
|
onTranslate,
|
||||||
|
text = '',
|
||||||
|
}) {
|
||||||
|
const targetLang = getTranslateTargetLanguage(true);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [pronunciationContent, setPronunciationContent] = useState(null);
|
||||||
|
const [translatedContent, setTranslatedContent] = useState(null);
|
||||||
|
const [detectedLang, setDetectedLang] = useState(null);
|
||||||
|
const detailsRef = useRef();
|
||||||
|
|
||||||
|
const sourceLangText = sourceLanguage
|
||||||
|
? localeCode2Text(sourceLanguage)
|
||||||
|
: null;
|
||||||
|
const targetLangText = localeCode2Text(targetLang);
|
||||||
|
const apiSourceLang = useRef('auto');
|
||||||
|
|
||||||
|
if (!onTranslate)
|
||||||
|
onTranslate = (source, target) => {
|
||||||
|
console.log('TRANSLATE', source, target, text);
|
||||||
|
// Using another API instance instead of lingva.ml because of this bug (slashes don't work):
|
||||||
|
// https://github.com/thedaviddelta/lingva-translate/issues/68
|
||||||
|
return fetch(
|
||||||
|
`https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent(
|
||||||
|
text,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => {
|
||||||
|
return {
|
||||||
|
provider: 'lingva',
|
||||||
|
content: res.translation,
|
||||||
|
detectedSourceLanguage: res.info.detectedSource,
|
||||||
|
info: res.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// return masto.v1.statuses.translate(id, {
|
||||||
|
// lang: DEFAULT_LANG,
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
const translate = async () => {
|
||||||
|
setUIState('loading');
|
||||||
|
const { content, detectedSourceLanguage, provider, ...props } =
|
||||||
|
await onTranslate(apiSourceLang.current, targetLang);
|
||||||
|
if (content) {
|
||||||
|
if (detectedSourceLanguage) {
|
||||||
|
const detectedLangText = localeCode2Text(detectedSourceLanguage);
|
||||||
|
setDetectedLang(detectedLangText);
|
||||||
|
}
|
||||||
|
if (provider === 'lingva') {
|
||||||
|
const pronunciation = props?.info?.pronunciation?.query;
|
||||||
|
if (pronunciation) {
|
||||||
|
setPronunciationContent(pronunciation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTranslatedContent(content);
|
||||||
|
setUIState('default');
|
||||||
|
detailsRef.current.open = true;
|
||||||
|
detailsRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(result);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (forceTranslate) {
|
||||||
|
translate();
|
||||||
|
}
|
||||||
|
}, [forceTranslate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="status-translation-block">
|
||||||
|
<details ref={detailsRef}>
|
||||||
|
<summary>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
detailsRef.current.open = !detailsRef.current.open;
|
||||||
|
if (uiState === 'loading') return;
|
||||||
|
if (!translatedContent) translate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="translate" />{' '}
|
||||||
|
<span>
|
||||||
|
{uiState === 'loading'
|
||||||
|
? 'Translating…'
|
||||||
|
: sourceLanguage && !detectedLang
|
||||||
|
? `Translate from ${sourceLangText}`
|
||||||
|
: `Translate`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</summary>
|
||||||
|
<div class="translated-block">
|
||||||
|
<div class="translation-info insignificant">
|
||||||
|
<select
|
||||||
|
class="translated-source-select"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onChange={(e) => {
|
||||||
|
apiSourceLang.current = e.target.value;
|
||||||
|
translate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sourceLanguages.map((l) => (
|
||||||
|
<option value={l.code}>
|
||||||
|
{l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>{' '}
|
||||||
|
<span>→ {targetLangText}</span>
|
||||||
|
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||||
|
</div>
|
||||||
|
{uiState === 'error' ? (
|
||||||
|
<p class="ui-state">Failed to translate</p>
|
||||||
|
) : (
|
||||||
|
!!translatedContent && (
|
||||||
|
<>
|
||||||
|
{!!pronunciationContent && (
|
||||||
|
<output class="translated-pronunciation-content">
|
||||||
|
{pronunciationContent}
|
||||||
|
</output>
|
||||||
|
)}
|
||||||
|
<output class="translated-content" lang={targetLang}>
|
||||||
|
{translatedContent}
|
||||||
|
</output>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TranslationBlock;
|
534
src/data/lingva-source-languages.json
Normal file
534
src/data/lingva-source-languages.json
Normal file
|
@ -0,0 +1,534 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "auto",
|
||||||
|
"name": "Detect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "af",
|
||||||
|
"name": "Afrikaans"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sq",
|
||||||
|
"name": "Albanian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "am",
|
||||||
|
"name": "Amharic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ar",
|
||||||
|
"name": "Arabic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hy",
|
||||||
|
"name": "Armenian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "as",
|
||||||
|
"name": "Assamese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ay",
|
||||||
|
"name": "Aymara"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "az",
|
||||||
|
"name": "Azerbaijani"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bm",
|
||||||
|
"name": "Bambara"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "eu",
|
||||||
|
"name": "Basque"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "be",
|
||||||
|
"name": "Belarusian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bn",
|
||||||
|
"name": "Bengali"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bho",
|
||||||
|
"name": "Bhojpuri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bs",
|
||||||
|
"name": "Bosnian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bg",
|
||||||
|
"name": "Bulgarian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ca",
|
||||||
|
"name": "Catalan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ceb",
|
||||||
|
"name": "Cebuano"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ny",
|
||||||
|
"name": "Chichewa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "zh",
|
||||||
|
"name": "Chinese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "co",
|
||||||
|
"name": "Corsican"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hr",
|
||||||
|
"name": "Croatian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cs",
|
||||||
|
"name": "Czech"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "da",
|
||||||
|
"name": "Danish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dv",
|
||||||
|
"name": "Dhivehi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "doi",
|
||||||
|
"name": "Dogri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "nl",
|
||||||
|
"name": "Dutch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "en",
|
||||||
|
"name": "English"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "eo",
|
||||||
|
"name": "Esperanto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "et",
|
||||||
|
"name": "Estonian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ee",
|
||||||
|
"name": "Ewe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tl",
|
||||||
|
"name": "Filipino"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fi",
|
||||||
|
"name": "Finnish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fr",
|
||||||
|
"name": "French"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fy",
|
||||||
|
"name": "Frisian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gl",
|
||||||
|
"name": "Galician"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ka",
|
||||||
|
"name": "Georgian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "de",
|
||||||
|
"name": "German"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "el",
|
||||||
|
"name": "Greek"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gn",
|
||||||
|
"name": "Guarani"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gu",
|
||||||
|
"name": "Gujarati"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ht",
|
||||||
|
"name": "Haitian Creole"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ha",
|
||||||
|
"name": "Hausa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "haw",
|
||||||
|
"name": "Hawaiian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "iw",
|
||||||
|
"name": "Hebrew"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hi",
|
||||||
|
"name": "Hindi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hmn",
|
||||||
|
"name": "Hmong"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hu",
|
||||||
|
"name": "Hungarian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "is",
|
||||||
|
"name": "Icelandic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ig",
|
||||||
|
"name": "Igbo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ilo",
|
||||||
|
"name": "Ilocano"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "id",
|
||||||
|
"name": "Indonesian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ga",
|
||||||
|
"name": "Irish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "it",
|
||||||
|
"name": "Italian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ja",
|
||||||
|
"name": "Japanese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "jw",
|
||||||
|
"name": "Javanese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "kn",
|
||||||
|
"name": "Kannada"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "kk",
|
||||||
|
"name": "Kazakh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "km",
|
||||||
|
"name": "Khmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "rw",
|
||||||
|
"name": "Kinyarwanda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gom",
|
||||||
|
"name": "Konkani"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ko",
|
||||||
|
"name": "Korean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "kri",
|
||||||
|
"name": "Krio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ku",
|
||||||
|
"name": "Kurdish (Kurmanji)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ckb",
|
||||||
|
"name": "Kurdish (Sorani)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ky",
|
||||||
|
"name": "Kyrgyz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lo",
|
||||||
|
"name": "Lao"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "la",
|
||||||
|
"name": "Latin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lv",
|
||||||
|
"name": "Latvian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ln",
|
||||||
|
"name": "Lingala"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lt",
|
||||||
|
"name": "Lithuanian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lg",
|
||||||
|
"name": "Luganda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lb",
|
||||||
|
"name": "Luxembourgish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mk",
|
||||||
|
"name": "Macedonian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mai",
|
||||||
|
"name": "Maithili"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mg",
|
||||||
|
"name": "Malagasy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ms",
|
||||||
|
"name": "Malay"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ml",
|
||||||
|
"name": "Malayalam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mt",
|
||||||
|
"name": "Maltese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mi",
|
||||||
|
"name": "Maori"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mr",
|
||||||
|
"name": "Marathi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mni-Mtei",
|
||||||
|
"name": "Meiteilon (Manipuri)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lus",
|
||||||
|
"name": "Mizo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mn",
|
||||||
|
"name": "Mongolian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "my",
|
||||||
|
"name": "Myanmar (Burmese)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ne",
|
||||||
|
"name": "Nepali"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "no",
|
||||||
|
"name": "Norwegian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "or",
|
||||||
|
"name": "Odia (Oriya)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "om",
|
||||||
|
"name": "Oromo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ps",
|
||||||
|
"name": "Pashto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fa",
|
||||||
|
"name": "Persian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pl",
|
||||||
|
"name": "Polish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pt",
|
||||||
|
"name": "Portuguese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pa",
|
||||||
|
"name": "Punjabi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "qu",
|
||||||
|
"name": "Quechua"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ro",
|
||||||
|
"name": "Romanian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ru",
|
||||||
|
"name": "Russian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sm",
|
||||||
|
"name": "Samoan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sa",
|
||||||
|
"name": "Sanskrit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gd",
|
||||||
|
"name": "Scots Gaelic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "nso",
|
||||||
|
"name": "Sepedi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sr",
|
||||||
|
"name": "Serbian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "st",
|
||||||
|
"name": "Sesotho"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sn",
|
||||||
|
"name": "Shona"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sd",
|
||||||
|
"name": "Sindhi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "si",
|
||||||
|
"name": "Sinhala"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sk",
|
||||||
|
"name": "Slovak"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sl",
|
||||||
|
"name": "Slovenian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "so",
|
||||||
|
"name": "Somali"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "es",
|
||||||
|
"name": "Spanish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "su",
|
||||||
|
"name": "Sundanese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sw",
|
||||||
|
"name": "Swahili"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sv",
|
||||||
|
"name": "Swedish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tg",
|
||||||
|
"name": "Tajik"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ta",
|
||||||
|
"name": "Tamil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tt",
|
||||||
|
"name": "Tatar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "te",
|
||||||
|
"name": "Telugu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "th",
|
||||||
|
"name": "Thai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ti",
|
||||||
|
"name": "Tigrinya"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ts",
|
||||||
|
"name": "Tsonga"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tr",
|
||||||
|
"name": "Turkish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tk",
|
||||||
|
"name": "Turkmen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ak",
|
||||||
|
"name": "Twi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "uk",
|
||||||
|
"name": "Ukrainian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ur",
|
||||||
|
"name": "Urdu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ug",
|
||||||
|
"name": "Uyghur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "uz",
|
||||||
|
"name": "Uzbek"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "vi",
|
||||||
|
"name": "Vietnamese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cy",
|
||||||
|
"name": "Welsh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "xh",
|
||||||
|
"name": "Xhosa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "yi",
|
||||||
|
"name": "Yiddish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "yo",
|
||||||
|
"name": "Yoruba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "zu",
|
||||||
|
"name": "Zulu"
|
||||||
|
}
|
||||||
|
]
|
534
src/data/lingva-target-languages.json
Normal file
534
src/data/lingva-target-languages.json
Normal file
|
@ -0,0 +1,534 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "af",
|
||||||
|
"name": "Afrikaans"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sq",
|
||||||
|
"name": "Albanian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "am",
|
||||||
|
"name": "Amharic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ar",
|
||||||
|
"name": "Arabic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hy",
|
||||||
|
"name": "Armenian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "as",
|
||||||
|
"name": "Assamese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ay",
|
||||||
|
"name": "Aymara"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "az",
|
||||||
|
"name": "Azerbaijani"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bm",
|
||||||
|
"name": "Bambara"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "eu",
|
||||||
|
"name": "Basque"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "be",
|
||||||
|
"name": "Belarusian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bn",
|
||||||
|
"name": "Bengali"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bho",
|
||||||
|
"name": "Bhojpuri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bs",
|
||||||
|
"name": "Bosnian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bg",
|
||||||
|
"name": "Bulgarian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ca",
|
||||||
|
"name": "Catalan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ceb",
|
||||||
|
"name": "Cebuano"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ny",
|
||||||
|
"name": "Chichewa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "zh",
|
||||||
|
"name": "Chinese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "zh_HANT",
|
||||||
|
"name": "Chinese (Traditional)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "co",
|
||||||
|
"name": "Corsican"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hr",
|
||||||
|
"name": "Croatian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cs",
|
||||||
|
"name": "Czech"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "da",
|
||||||
|
"name": "Danish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dv",
|
||||||
|
"name": "Dhivehi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "doi",
|
||||||
|
"name": "Dogri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "nl",
|
||||||
|
"name": "Dutch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "en",
|
||||||
|
"name": "English"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "eo",
|
||||||
|
"name": "Esperanto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "et",
|
||||||
|
"name": "Estonian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ee",
|
||||||
|
"name": "Ewe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tl",
|
||||||
|
"name": "Filipino"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fi",
|
||||||
|
"name": "Finnish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fr",
|
||||||
|
"name": "French"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fy",
|
||||||
|
"name": "Frisian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gl",
|
||||||
|
"name": "Galician"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ka",
|
||||||
|
"name": "Georgian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "de",
|
||||||
|
"name": "German"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "el",
|
||||||
|
"name": "Greek"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gn",
|
||||||
|
"name": "Guarani"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gu",
|
||||||
|
"name": "Gujarati"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ht",
|
||||||
|
"name": "Haitian Creole"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ha",
|
||||||
|
"name": "Hausa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "haw",
|
||||||
|
"name": "Hawaiian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "iw",
|
||||||
|
"name": "Hebrew"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hi",
|
||||||
|
"name": "Hindi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hmn",
|
||||||
|
"name": "Hmong"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "hu",
|
||||||
|
"name": "Hungarian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "is",
|
||||||
|
"name": "Icelandic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ig",
|
||||||
|
"name": "Igbo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ilo",
|
||||||
|
"name": "Ilocano"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "id",
|
||||||
|
"name": "Indonesian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ga",
|
||||||
|
"name": "Irish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "it",
|
||||||
|
"name": "Italian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ja",
|
||||||
|
"name": "Japanese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "jw",
|
||||||
|
"name": "Javanese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "kn",
|
||||||
|
"name": "Kannada"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "kk",
|
||||||
|
"name": "Kazakh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "km",
|
||||||
|
"name": "Khmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "rw",
|
||||||
|
"name": "Kinyarwanda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gom",
|
||||||
|
"name": "Konkani"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ko",
|
||||||
|
"name": "Korean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "kri",
|
||||||
|
"name": "Krio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ku",
|
||||||
|
"name": "Kurdish (Kurmanji)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ckb",
|
||||||
|
"name": "Kurdish (Sorani)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ky",
|
||||||
|
"name": "Kyrgyz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lo",
|
||||||
|
"name": "Lao"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "la",
|
||||||
|
"name": "Latin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lv",
|
||||||
|
"name": "Latvian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ln",
|
||||||
|
"name": "Lingala"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lt",
|
||||||
|
"name": "Lithuanian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lg",
|
||||||
|
"name": "Luganda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lb",
|
||||||
|
"name": "Luxembourgish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mk",
|
||||||
|
"name": "Macedonian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mai",
|
||||||
|
"name": "Maithili"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mg",
|
||||||
|
"name": "Malagasy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ms",
|
||||||
|
"name": "Malay"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ml",
|
||||||
|
"name": "Malayalam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mt",
|
||||||
|
"name": "Maltese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mi",
|
||||||
|
"name": "Maori"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mr",
|
||||||
|
"name": "Marathi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mni-Mtei",
|
||||||
|
"name": "Meiteilon (Manipuri)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "lus",
|
||||||
|
"name": "Mizo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "mn",
|
||||||
|
"name": "Mongolian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "my",
|
||||||
|
"name": "Myanmar (Burmese)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ne",
|
||||||
|
"name": "Nepali"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "no",
|
||||||
|
"name": "Norwegian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "or",
|
||||||
|
"name": "Odia (Oriya)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "om",
|
||||||
|
"name": "Oromo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ps",
|
||||||
|
"name": "Pashto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fa",
|
||||||
|
"name": "Persian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pl",
|
||||||
|
"name": "Polish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pt",
|
||||||
|
"name": "Portuguese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "pa",
|
||||||
|
"name": "Punjabi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "qu",
|
||||||
|
"name": "Quechua"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ro",
|
||||||
|
"name": "Romanian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ru",
|
||||||
|
"name": "Russian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sm",
|
||||||
|
"name": "Samoan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sa",
|
||||||
|
"name": "Sanskrit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "gd",
|
||||||
|
"name": "Scots Gaelic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "nso",
|
||||||
|
"name": "Sepedi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sr",
|
||||||
|
"name": "Serbian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "st",
|
||||||
|
"name": "Sesotho"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sn",
|
||||||
|
"name": "Shona"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sd",
|
||||||
|
"name": "Sindhi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "si",
|
||||||
|
"name": "Sinhala"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sk",
|
||||||
|
"name": "Slovak"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sl",
|
||||||
|
"name": "Slovenian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "so",
|
||||||
|
"name": "Somali"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "es",
|
||||||
|
"name": "Spanish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "su",
|
||||||
|
"name": "Sundanese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sw",
|
||||||
|
"name": "Swahili"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sv",
|
||||||
|
"name": "Swedish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tg",
|
||||||
|
"name": "Tajik"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ta",
|
||||||
|
"name": "Tamil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tt",
|
||||||
|
"name": "Tatar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "te",
|
||||||
|
"name": "Telugu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "th",
|
||||||
|
"name": "Thai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ti",
|
||||||
|
"name": "Tigrinya"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ts",
|
||||||
|
"name": "Tsonga"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tr",
|
||||||
|
"name": "Turkish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "tk",
|
||||||
|
"name": "Turkmen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ak",
|
||||||
|
"name": "Twi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "uk",
|
||||||
|
"name": "Ukrainian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ur",
|
||||||
|
"name": "Urdu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ug",
|
||||||
|
"name": "Uyghur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "uz",
|
||||||
|
"name": "Uzbek"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "vi",
|
||||||
|
"name": "Vietnamese"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cy",
|
||||||
|
"name": "Welsh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "xh",
|
||||||
|
"name": "Xhosa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "yi",
|
||||||
|
"name": "Yiddish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "yo",
|
||||||
|
"name": "Yoruba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "zu",
|
||||||
|
"name": "Zulu"
|
||||||
|
}
|
||||||
|
]
|
|
@ -2,6 +2,12 @@
|
||||||
@custom-media --viewport-medium (min-width: 40em);
|
@custom-media --viewport-medium (min-width: 40em);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--sai-top: env(safe-area-inset-top);
|
||||||
|
--sai-right: env(safe-area-inset-right);
|
||||||
|
--sai-bottom: env(safe-area-inset-bottom);
|
||||||
|
--sai-left: env(safe-area-inset-left);
|
||||||
|
|
||||||
|
--text-size: 16px;
|
||||||
--main-width: 40em;
|
--main-width: 40em;
|
||||||
text-size-adjust: none;
|
text-size-adjust: none;
|
||||||
--hairline-width: 1px;
|
--hairline-width: 1px;
|
||||||
|
@ -90,7 +96,7 @@ html {
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: ui-rounded, system-ui;
|
font-family: ui-rounded, system-ui;
|
||||||
font-size: 16px;
|
font-size: var(--text-size);
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
@ -353,3 +359,18 @@ code {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes position-object {
|
||||||
|
0% {
|
||||||
|
object-position: 50% 50%;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
object-position: 0% 0%;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
object-position: 100% 100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
object-position: 50% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import AccountInfo from '../components/account-info';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import emojifyText from '../utils/emojify-text';
|
import emojifyText from '../utils/emojify-text';
|
||||||
|
@ -13,7 +14,7 @@ const LIMIT = 20;
|
||||||
function AccountStatuses() {
|
function AccountStatuses() {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const { id, ...params } = useParams();
|
const { id, ...params } = useParams();
|
||||||
const { masto, instance } = api({ instance: params.instance });
|
const { masto, instance, authenticated } = api({ instance: params.instance });
|
||||||
const accountStatusesIterator = useRef();
|
const accountStatusesIterator = useRef();
|
||||||
async function fetchAccountStatuses(firstLoad) {
|
async function fetchAccountStatuses(firstLoad) {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
@ -27,7 +28,7 @@ function AccountStatuses() {
|
||||||
pinnedStatuses.forEach((status) => {
|
pinnedStatuses.forEach((status) => {
|
||||||
status._pinned = true;
|
status._pinned = true;
|
||||||
});
|
});
|
||||||
if (pinnedStatuses.length > 1) {
|
if (pinnedStatuses.length >= 3) {
|
||||||
const pinnedStatusesIds = pinnedStatuses.map((status) => status.id);
|
const pinnedStatusesIds = pinnedStatuses.map((status) => status.id);
|
||||||
results.push({
|
results.push({
|
||||||
id: pinnedStatusesIds,
|
id: pinnedStatusesIds,
|
||||||
|
@ -54,9 +55,11 @@ function AccountStatuses() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [account, setAccount] = useState({});
|
const [account, setAccount] = useState();
|
||||||
useTitle(
|
useTitle(
|
||||||
`${account?.acct ? '@' + account.acct : 'Posts'}`,
|
`${account?.displayName ? account.displayName + ' ' : ''}@${
|
||||||
|
account?.acct ? account.acct : 'Account posts'
|
||||||
|
}`,
|
||||||
'/:instance?/a/:id',
|
'/:instance?/a/:id',
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -71,7 +74,20 @@ function AccountStatuses() {
|
||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const { displayName, acct, emojis } = account;
|
const { displayName, acct, emojis } = account || {};
|
||||||
|
|
||||||
|
const TimelineStart = useMemo(() => {
|
||||||
|
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
|
||||||
|
return (
|
||||||
|
<AccountInfo
|
||||||
|
instance={instance}
|
||||||
|
account={cachedAccount || id}
|
||||||
|
fetchAccount={() => masto.v1.accounts.fetch(id)}
|
||||||
|
authenticated={authenticated}
|
||||||
|
standalone
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [id, instance, authenticated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Timeline
|
<Timeline
|
||||||
|
@ -103,6 +119,7 @@ function AccountStatuses() {
|
||||||
errorText="Unable to load statuses"
|
errorText="Unable to load statuses"
|
||||||
fetchItems={fetchAccountStatuses}
|
fetchItems={fetchAccountStatuses}
|
||||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||||
|
timelineStart={TimelineStart}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
152
src/pages/accounts.jsx
Normal file
152
src/pages/accounts.jsx
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import './settings.css';
|
||||||
|
|
||||||
|
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||||
|
import { useReducer, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Avatar from '../components/avatar';
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
|
import NameText from '../components/name-text';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import states from '../utils/states';
|
||||||
|
import store from '../utils/store';
|
||||||
|
|
||||||
|
function Accounts({ onClose }) {
|
||||||
|
const { masto } = api();
|
||||||
|
// Accounts
|
||||||
|
const accounts = store.local.getJSON('accounts');
|
||||||
|
const currentAccount = store.session.get('currentAccount');
|
||||||
|
const moreThanOneAccount = accounts.length > 1;
|
||||||
|
const [currentDefault, setCurrentDefault] = useState(0);
|
||||||
|
|
||||||
|
const [_, reload] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||||
|
<header class="header-grid">
|
||||||
|
<h2>Accounts</h2>
|
||||||
|
<div class="header-side">
|
||||||
|
<Link to="/login" class="button plain" onClick={onClose}>
|
||||||
|
<Icon icon="plus" /> <span>Account</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<ul class="accounts-list">
|
||||||
|
{accounts.map((account, i) => {
|
||||||
|
const isCurrent = account.info.id === currentAccount;
|
||||||
|
const isDefault = i === (currentDefault || 0);
|
||||||
|
return (
|
||||||
|
<li key={i + account.id}>
|
||||||
|
<div>
|
||||||
|
{moreThanOneAccount && (
|
||||||
|
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
||||||
|
<Icon icon="check-circle" alt="Current" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Avatar
|
||||||
|
url={account.info.avatarStatic}
|
||||||
|
size="xxl"
|
||||||
|
onDblClick={async () => {
|
||||||
|
if (isCurrent) {
|
||||||
|
try {
|
||||||
|
const info = await masto.v1.accounts.fetch(
|
||||||
|
account.info.id,
|
||||||
|
);
|
||||||
|
console.log('fetched account info', info);
|
||||||
|
account.info = info;
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
reload();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NameText
|
||||||
|
account={account.info}
|
||||||
|
showAcct
|
||||||
|
onClick={() => {
|
||||||
|
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
{isDefault && moreThanOneAccount && (
|
||||||
|
<>
|
||||||
|
<span class="tag">Default</span>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
store.session.set('currentAccount', account.info.id);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="transfer" /> Switch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Menu
|
||||||
|
align="end"
|
||||||
|
menuButton={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="More"
|
||||||
|
class="plain more-button"
|
||||||
|
>
|
||||||
|
<Icon icon="more" size="l" alt="More" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{moreThanOneAccount && (
|
||||||
|
<MenuItem
|
||||||
|
disabled={isDefault}
|
||||||
|
onClick={() => {
|
||||||
|
// Move account to the top of the list
|
||||||
|
accounts.splice(i, 1);
|
||||||
|
accounts.unshift(account);
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
setCurrentDefault(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="check-circle" />
|
||||||
|
<span>Set as default</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
disabled={!isCurrent}
|
||||||
|
onClick={() => {
|
||||||
|
const yes = confirm('Log out?');
|
||||||
|
if (!yes) return;
|
||||||
|
accounts.splice(i, 1);
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
// location.reload();
|
||||||
|
location.href = '/';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="exit" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{moreThanOneAccount && (
|
||||||
|
<p>
|
||||||
|
<small>
|
||||||
|
Note: <i>Default</i> account will always be used for first load.
|
||||||
|
Switched accounts will persist during the session.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Accounts;
|
|
@ -303,7 +303,8 @@ function Notification({ notification, instance }) {
|
||||||
for (const account of _accounts) {
|
for (const account of _accounts) {
|
||||||
if (account._types?.includes('favourite')) {
|
if (account._types?.includes('favourite')) {
|
||||||
favsCount++;
|
favsCount++;
|
||||||
} else if (account._types?.includes('reblog')) {
|
}
|
||||||
|
if (account._types?.includes('reblog')) {
|
||||||
reblogsCount++;
|
reblogsCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -428,7 +429,7 @@ function Notification({ notification, instance }) {
|
||||||
: `/s/${actualStatusID}`
|
: `/s/${actualStatusID}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Status status={status} size="s" />
|
<Status statusID={actualStatusID} size="s" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,19 +2,16 @@
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings-container h2 {
|
#settings-container main h3 {
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
#settings-container h2 ~ h2 {
|
|
||||||
margin-top: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings-container section {
|
#settings-container section {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
margin: 0;
|
margin: 8px 0 0;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-top: var(--hairline-width) solid var(--outline-color);
|
border-top: var(--hairline-width) solid var(--outline-color);
|
||||||
border-bottom: var(--hairline-width) solid var(--outline-color);
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
@ -30,7 +27,7 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
#settings-container section > ul > li {
|
#settings-container section > ul > li {
|
||||||
padding: 8px 0 16px;
|
padding: 8px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -48,6 +45,9 @@
|
||||||
#settings-container section > ul > li .current.is-current + .avatar {
|
#settings-container section > ul > li .current.is-current + .avatar {
|
||||||
box-shadow: 0 0 0 1.5px var(--green-color), 0 0 8px var(--green-color);
|
box-shadow: 0 0 0 1.5px var(--green-color), 0 0 8px var(--green-color);
|
||||||
}
|
}
|
||||||
|
#settings-container section > ul > li .avatar + .name-text {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
#settings-container section > ul > li > div {
|
#settings-container section > ul > li > div {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
@ -59,14 +59,28 @@
|
||||||
#settings-container section > ul > li > div:last-child {
|
#settings-container section > ul > li > div:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
#settings-container div,
|
#settings-container section > ul > li .sub-section {
|
||||||
#settings-container div > * {
|
text-align: left !important;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
#settings-container section > ul > li .sub-section p {
|
||||||
|
margin-block: 0.5em;
|
||||||
|
}
|
||||||
|
#settings-container section > ul > li .sub-section p:last-child {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
#settings-container div {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
#settings-container .avatar {
|
#settings-container .avatar {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#settings-container section select {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
#settings-container .radio-group {
|
#settings-container .radio-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -100,3 +114,12 @@
|
||||||
#settings-container .radio-group label:has(input:checked) input:checked + span {
|
#settings-container .radio-group label:has(input:checked) input:checked + span {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#settings-container .range-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#settings-container .range-group input[type='range'] {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
|
@ -1,163 +1,35 @@
|
||||||
import './settings.css';
|
import './settings.css';
|
||||||
|
|
||||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
import { useRef } from 'preact/hooks';
|
||||||
import { useReducer, useRef, useState } from 'preact/hooks';
|
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import logo from '../assets/logo.svg';
|
import logo from '../assets/logo.svg';
|
||||||
import Avatar from '../components/avatar';
|
|
||||||
import Icon from '../components/icon';
|
|
||||||
import Link from '../components/link';
|
|
||||||
import NameText from '../components/name-text';
|
|
||||||
import RelativeTime from '../components/relative-time';
|
import RelativeTime from '../components/relative-time';
|
||||||
import { api } from '../utils/api';
|
import targetLanguages from '../data/lingva-target-languages';
|
||||||
|
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||||
|
import localeCode2Text from '../utils/localeCode2Text';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
|
||||||
/*
|
const DEFAULT_TEXT_SIZE = 16;
|
||||||
Settings component that shows these settings:
|
const TEXT_SIZES = [16, 17, 18, 19, 20];
|
||||||
- Accounts list for switching
|
|
||||||
- Dark/light/auto theme switch (done with adding/removing 'is-light' or 'is-dark' class on the body)
|
|
||||||
*/
|
|
||||||
|
|
||||||
function Settings({ onClose }) {
|
function Settings({ onClose }) {
|
||||||
const { masto } = api();
|
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
// Accounts
|
|
||||||
const accounts = store.local.getJSON('accounts');
|
|
||||||
const currentAccount = store.session.get('currentAccount');
|
|
||||||
const currentTheme = store.local.get('theme') || 'auto';
|
const currentTheme = store.local.get('theme') || 'auto';
|
||||||
const themeFormRef = useRef();
|
const themeFormRef = useRef();
|
||||||
const moreThanOneAccount = accounts.length > 1;
|
const targetLanguage =
|
||||||
const [currentDefault, setCurrentDefault] = useState(0);
|
snapStates.settings.contentTranslationTargetLanguage || null;
|
||||||
|
const systemTargetLanguage = getTranslateTargetLanguage();
|
||||||
const [_, reload] = useReducer((x) => x + 1, 0);
|
const systemTargetLanguageText = localeCode2Text(systemTargetLanguage);
|
||||||
|
const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="settings-container" class="sheet" tabIndex="-1">
|
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||||
<main>
|
<header>
|
||||||
{/* <button type="button" class="close-button plain large" onClick={onClose}>
|
|
||||||
<Icon icon="x" alt="Close" />
|
|
||||||
</button> */}
|
|
||||||
<h2>Accounts</h2>
|
|
||||||
<section>
|
|
||||||
<ul class="accounts-list">
|
|
||||||
{accounts.map((account, i) => {
|
|
||||||
const isCurrent = account.info.id === currentAccount;
|
|
||||||
const isDefault = i === (currentDefault || 0);
|
|
||||||
return (
|
|
||||||
<li key={i + account.id}>
|
|
||||||
<div>
|
|
||||||
{moreThanOneAccount && (
|
|
||||||
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
|
||||||
<Icon icon="check-circle" alt="Current" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Avatar
|
|
||||||
url={account.info.avatarStatic}
|
|
||||||
size="xxl"
|
|
||||||
onDblClick={async () => {
|
|
||||||
if (isCurrent) {
|
|
||||||
try {
|
|
||||||
const info = await masto.v1.accounts.fetch(
|
|
||||||
account.info.id,
|
|
||||||
);
|
|
||||||
console.log('fetched account info', info);
|
|
||||||
account.info = info;
|
|
||||||
store.local.setJSON('accounts', accounts);
|
|
||||||
reload();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NameText
|
|
||||||
account={account.info}
|
|
||||||
showAcct
|
|
||||||
onClick={() => {
|
|
||||||
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
{isDefault && moreThanOneAccount && (
|
|
||||||
<>
|
|
||||||
<span class="tag">Default</span>{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isCurrent && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="light"
|
|
||||||
onClick={() => {
|
|
||||||
store.session.set('currentAccount', account.info.id);
|
|
||||||
location.reload();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="transfer" /> Switch
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Menu
|
|
||||||
align="end"
|
|
||||||
menuButton={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="More"
|
|
||||||
class="plain more-button"
|
|
||||||
>
|
|
||||||
<Icon icon="more" size="l" alt="More" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{moreThanOneAccount && (
|
|
||||||
<MenuItem
|
|
||||||
disabled={isDefault}
|
|
||||||
onClick={() => {
|
|
||||||
// Move account to the top of the list
|
|
||||||
accounts.splice(i, 1);
|
|
||||||
accounts.unshift(account);
|
|
||||||
store.local.setJSON('accounts', accounts);
|
|
||||||
setCurrentDefault(i);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="check-circle" />
|
|
||||||
<span>Set as default</span>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
disabled={!isCurrent}
|
|
||||||
onClick={() => {
|
|
||||||
const yes = confirm('Log out?');
|
|
||||||
if (!yes) return;
|
|
||||||
accounts.splice(i, 1);
|
|
||||||
store.local.setJSON('accounts', accounts);
|
|
||||||
// location.reload();
|
|
||||||
location.href = '/';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="exit" />
|
|
||||||
<span>Log out</span>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
{moreThanOneAccount && (
|
|
||||||
<p>
|
|
||||||
<small>
|
|
||||||
Note: <i>Default</i> account will always be used for first load.
|
|
||||||
Switched accounts will persist during the session.
|
|
||||||
</small>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p style={{ textAlign: 'end' }}>
|
|
||||||
<Link to="/login" class="button" onClick={onClose}>
|
|
||||||
Add new account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
@ -228,6 +100,47 @@ function Settings({ onClose }) {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<label>Text size</label>
|
||||||
|
</div>
|
||||||
|
<div class="range-group">
|
||||||
|
<span style={{ fontSize: TEXT_SIZES[0] }}>A</span>{' '}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={TEXT_SIZES[0]}
|
||||||
|
max={TEXT_SIZES[TEXT_SIZES.length - 1]}
|
||||||
|
step="1"
|
||||||
|
value={currentTextSize}
|
||||||
|
list="sizes"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
const html = document.documentElement;
|
||||||
|
// set CSS variable
|
||||||
|
html.style.setProperty('--text-size', `${value}px`);
|
||||||
|
// save to local storage
|
||||||
|
if (value === DEFAULT_TEXT_SIZE) {
|
||||||
|
store.local.del('textSize');
|
||||||
|
} else {
|
||||||
|
store.local.set('textSize', e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<span style={{ fontSize: TEXT_SIZES[TEXT_SIZES.length - 1] }}>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<datalist id="sizes">
|
||||||
|
{TEXT_SIZES.map((size) => (
|
||||||
|
<option value={size} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<h3>Experiments</h3>
|
||||||
|
<section>
|
||||||
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
@ -237,78 +150,143 @@ function Settings({ onClose }) {
|
||||||
states.settings.boostsCarousel = e.target.checked;
|
states.settings.boostsCarousel = e.target.checked;
|
||||||
}}
|
}}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
Boosts carousel (experimental)
|
Boosts carousel
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snapStates.settings.contentTranslation}
|
||||||
|
onChange={(e) => {
|
||||||
|
const { checked } = e.target;
|
||||||
|
states.settings.contentTranslation = checked;
|
||||||
|
if (!checked) {
|
||||||
|
states.settings.contentTranslationTargetLanguage = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
Post translation
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class={`sub-section ${
|
||||||
|
!snapStates.settings.contentTranslation
|
||||||
|
? 'more-insignificant'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Translate to{' '}
|
||||||
|
<select
|
||||||
|
value={targetLanguage || ''}
|
||||||
|
disabled={!snapStates.settings.contentTranslation}
|
||||||
|
onChange={(e) => {
|
||||||
|
states.settings.contentTranslationTargetLanguage =
|
||||||
|
e.target.value || null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
System language ({systemTargetLanguageText})
|
||||||
|
</option>
|
||||||
|
<option disabled>──────────</option>
|
||||||
|
{targetLanguages.map((lang) => (
|
||||||
|
<option value={lang.code}>{lang.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p>
|
||||||
|
<small>
|
||||||
|
Note: This feature uses an external API to translate,
|
||||||
|
powered by{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/thedaviddelta/lingva-translate"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Lingva Translate
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
states.showDrafts = true;
|
||||||
|
states.showSettings = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unsent drafts
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<h2>Hidden features</h2>
|
<h3>About</h3>
|
||||||
<section>
|
<section>
|
||||||
<div>
|
<div
|
||||||
<button
|
style={{
|
||||||
type="button"
|
display: 'flex',
|
||||||
class="light"
|
gap: 8,
|
||||||
onClick={() => {
|
lineHeight: 1.25,
|
||||||
states.showDrafts = true;
|
alignItems: 'center',
|
||||||
states.showSettings = false;
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Unsent drafts
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<h2>About</h2>
|
|
||||||
<section>
|
|
||||||
<p>
|
|
||||||
<img
|
<img
|
||||||
src={logo}
|
src={logo}
|
||||||
alt=""
|
alt=""
|
||||||
width="20"
|
width="64"
|
||||||
height="20"
|
height="64"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: '1/1',
|
aspectRatio: '1/1',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
background: '#b7cdf9',
|
||||||
|
borderRadius: 12,
|
||||||
}}
|
}}
|
||||||
/>{' '}
|
/>
|
||||||
<a
|
<div>
|
||||||
href="https://hachyderm.io/@phanpy"
|
<b>Phanpy</b>{' '}
|
||||||
// target="_blank"
|
<a
|
||||||
onClick={(e) => {
|
href="https://hachyderm.io/@phanpy"
|
||||||
e.preventDefault();
|
// target="_blank"
|
||||||
states.showAccount = 'phanpy@hachyderm.io';
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
states.showAccount = 'phanpy@hachyderm.io';
|
||||||
@phanpy
|
}}
|
||||||
</a>
|
>
|
||||||
.
|
@phanpy
|
||||||
</p>
|
</a>
|
||||||
|
<br />
|
||||||
|
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||||
|
Built
|
||||||
|
</a>{' '}
|
||||||
|
by{' '}
|
||||||
|
<a
|
||||||
|
href="https://mastodon.social/@cheeaun"
|
||||||
|
// target="_blank"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
states.showAccount = 'cheeaun@mastodon.social';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@cheeaun
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
|
||||||
Built
|
|
||||||
</a>{' '}
|
|
||||||
by{' '}
|
|
||||||
<a
|
|
||||||
href="https://mastodon.social/@cheeaun"
|
|
||||||
// target="_blank"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
states.showAccount = 'cheeaun@mastodon.social';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
@cheeaun
|
|
||||||
</a>
|
|
||||||
.{' '}
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</a>
|
</a>
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
{__BUILD_TIME__ && (
|
{__BUILD_TIME__ && (
|
||||||
<p>
|
<p>
|
||||||
Last build: <RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
|
<span class="insignificant">Last build:</span>{' '}
|
||||||
|
<RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
|
||||||
{__COMMIT_HASH__ && (
|
{__COMMIT_HASH__ && (
|
||||||
<>
|
<>
|
||||||
(
|
(
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-heading {
|
.hero-heading {
|
||||||
font-size: 16px;
|
font-size: var(--text-size);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.hero-heading .icon {
|
.hero-heading .icon {
|
||||||
|
|
|
@ -78,7 +78,7 @@ function StatusPage() {
|
||||||
}, [id, uiState !== 'loading']);
|
}, [id, uiState !== 'loading']);
|
||||||
|
|
||||||
const scrollOffsets = useRef();
|
const scrollOffsets = useRef();
|
||||||
const initContext = () => {
|
const initContext = ({ reloadHero } = {}) => {
|
||||||
console.debug('initContext', id);
|
console.debug('initContext', id);
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
let heroTimer;
|
let heroTimer;
|
||||||
|
@ -114,7 +114,7 @@ function StatusPage() {
|
||||||
|
|
||||||
const hasStatus = !!snapStates.statuses[sKey];
|
const hasStatus = !!snapStates.statuses[sKey];
|
||||||
let heroStatus = snapStates.statuses[sKey];
|
let heroStatus = snapStates.statuses[sKey];
|
||||||
if (hasStatus) {
|
if (hasStatus && !reloadHero) {
|
||||||
console.debug('Hero status is cached');
|
console.debug('Hero status is cached');
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
@ -277,7 +277,9 @@ function StatusPage() {
|
||||||
const apiCache = await caches.open('api');
|
const apiCache = await caches.open('api');
|
||||||
await apiCache.delete(contextURL, { ignoreVary: true });
|
await apiCache.delete(contextURL, { ignoreVary: true });
|
||||||
|
|
||||||
return initContext();
|
return initContext({
|
||||||
|
reloadHero: true,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
@ -624,6 +626,7 @@ function StatusPage() {
|
||||||
instance={instance}
|
instance={instance}
|
||||||
withinContext
|
withinContext
|
||||||
size="l"
|
size="l"
|
||||||
|
enableTranslate
|
||||||
/>
|
/>
|
||||||
</InView>
|
</InView>
|
||||||
{uiState !== 'loading' && !authenticated ? (
|
{uiState !== 'loading' && !authenticated ? (
|
||||||
|
@ -700,6 +703,7 @@ function StatusPage() {
|
||||||
instance={instance}
|
instance={instance}
|
||||||
withinContext
|
withinContext
|
||||||
size={thread || ancestor ? 'm' : 's'}
|
size={thread || ancestor ? 'm' : 's'}
|
||||||
|
enableTranslate
|
||||||
/>
|
/>
|
||||||
{/* {replies?.length > LIMIT && (
|
{/* {replies?.length > LIMIT && (
|
||||||
<div class="replies-link">
|
<div class="replies-link">
|
||||||
|
@ -880,6 +884,7 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
|
||||||
instance={instance}
|
instance={instance}
|
||||||
withinContext
|
withinContext
|
||||||
size="s"
|
size="s"
|
||||||
|
enableTranslate
|
||||||
/>
|
/>
|
||||||
{!r.replies?.length && r.repliesCount > 0 && (
|
{!r.replies?.length && r.repliesCount > 0 && (
|
||||||
<div class="replies-link">
|
<div class="replies-link">
|
||||||
|
|
24
src/utils/get-translate-target-language.jsx
Normal file
24
src/utils/get-translate-target-language.jsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { match } from '@formatjs/intl-localematcher';
|
||||||
|
|
||||||
|
import translationTargetLanguages from '../data/lingva-target-languages';
|
||||||
|
|
||||||
|
import states from './states';
|
||||||
|
|
||||||
|
function getTranslateTargetLanguage(fromSettings = false) {
|
||||||
|
if (fromSettings) {
|
||||||
|
const { contentTranslationTargetLanguage } = states.settings;
|
||||||
|
if (contentTranslationTargetLanguage) {
|
||||||
|
return contentTranslationTargetLanguage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match(
|
||||||
|
[
|
||||||
|
new Intl.DateTimeFormat().resolvedOptions().locale,
|
||||||
|
...navigator.languages,
|
||||||
|
],
|
||||||
|
translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match`
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getTranslateTargetLanguage;
|
|
@ -45,6 +45,9 @@ function handleContentLinks(opts) {
|
||||||
} else if (states.unfurledLinks[target.href]?.url) {
|
} else if (states.unfurledLinks[target.href]?.url) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
states.prevLocation = {
|
||||||
|
pathname: location.hash.replace(/^#/, ''),
|
||||||
|
};
|
||||||
location.hash = `#${states.unfurledLinks[target.href].url}`;
|
location.hash = `#${states.unfurledLinks[target.href].url}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
5
src/utils/localeCode2Text.jsx
Normal file
5
src/utils/localeCode2Text.jsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export default function localeCode2Text(code) {
|
||||||
|
return new Intl.DisplayNames(navigator.languages, {
|
||||||
|
type: 'language',
|
||||||
|
}).of(code);
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
function niceDateTime(date, { hideTime } = {}) {
|
function niceDateTime(date, { hideTime, formatOpts } = {}) {
|
||||||
if (!(date instanceof Date)) {
|
if (!(date instanceof Date)) {
|
||||||
date = new Date(date);
|
date = new Date(date);
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ function niceDateTime(date, { hideTime } = {}) {
|
||||||
// Hide time if requested
|
// Hide time if requested
|
||||||
hour: hideTime ? undefined : 'numeric',
|
hour: hideTime ? undefined : 'numeric',
|
||||||
minute: hideTime ? undefined : 'numeric',
|
minute: hideTime ? undefined : 'numeric',
|
||||||
|
...formatOpts,
|
||||||
}).format(date);
|
}).format(date);
|
||||||
return dateText;
|
return dateText;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,14 @@ function showToast(props) {
|
||||||
if (typeof props === 'string') {
|
if (typeof props === 'string') {
|
||||||
props = { text: props };
|
props = { text: props };
|
||||||
}
|
}
|
||||||
const { onClick = () => {}, delay, ...rest } = props;
|
const { onClick, delay, ...rest } = props;
|
||||||
const toast = Toastify({
|
const toast = Toastify({
|
||||||
className: 'shiny-pill',
|
className: `${onClick || props.destination ? 'shiny-pill' : ''}`,
|
||||||
gravity: 'bottom',
|
gravity: 'bottom',
|
||||||
position: 'center',
|
position: 'center',
|
||||||
...rest,
|
...rest,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onClick(toast); // Pass in the object itself!
|
onClick?.(toast); // Pass in the object itself!
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (delay) {
|
if (delay) {
|
||||||
|
|
|
@ -27,10 +27,12 @@ const states = proxy({
|
||||||
spoilers: {},
|
spoilers: {},
|
||||||
scrollPositions: {},
|
scrollPositions: {},
|
||||||
unfurledLinks: {},
|
unfurledLinks: {},
|
||||||
|
accounts: {},
|
||||||
// Modals
|
// Modals
|
||||||
showCompose: false,
|
showCompose: false,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
showAccount: false,
|
showAccount: false,
|
||||||
|
showAccounts: false,
|
||||||
showDrafts: false,
|
showDrafts: false,
|
||||||
showMediaModal: false,
|
showMediaModal: false,
|
||||||
showShortcutsSettings: false,
|
showShortcutsSettings: false,
|
||||||
|
@ -42,6 +44,10 @@ const states = proxy({
|
||||||
shortcutsColumnsMode:
|
shortcutsColumnsMode:
|
||||||
store.account.get('settings-shortcutsColumnsMode') ?? false,
|
store.account.get('settings-shortcutsColumnsMode') ?? false,
|
||||||
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
|
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
|
||||||
|
contentTranslation:
|
||||||
|
store.account.get('settings-contentTranslation') ?? true,
|
||||||
|
contentTranslationTargetLanguage:
|
||||||
|
store.account.get('settings-contentTranslationTargetLanguage') || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -51,20 +57,28 @@ subscribeKey(states, 'notificationsLast', (v) => {
|
||||||
console.log('CHANGE', v);
|
console.log('CHANGE', v);
|
||||||
store.account.set('notificationsLast', states.notificationsLast);
|
store.account.set('notificationsLast', states.notificationsLast);
|
||||||
});
|
});
|
||||||
subscribe(states, (v) => {
|
subscribe(states, (changes) => {
|
||||||
console.debug('STATES change', v);
|
console.debug('STATES change', changes);
|
||||||
const [action, path, value, prevValue] = v[0];
|
for (const [action, path, value, prevValue] of changes) {
|
||||||
if (path.join('.') === 'settings.boostsCarousel') {
|
if (path.join('.') === 'settings.boostsCarousel') {
|
||||||
store.account.set('settings-boostsCarousel', !!value);
|
store.account.set('settings-boostsCarousel', !!value);
|
||||||
}
|
}
|
||||||
if (path.join('.') === 'settings.shortcutsColumnsMode') {
|
if (path.join('.') === 'settings.shortcutsColumnsMode') {
|
||||||
store.account.set('settings-shortcutsColumnsMode', !!value);
|
store.account.set('settings-shortcutsColumnsMode', !!value);
|
||||||
}
|
}
|
||||||
if (path.join('.') === 'settings.shortcutsViewMode') {
|
if (path.join('.') === 'settings.shortcutsViewMode') {
|
||||||
store.account.set('settings-shortcutsViewMode', value);
|
store.account.set('settings-shortcutsViewMode', value);
|
||||||
}
|
}
|
||||||
if (path?.[0] === 'shortcuts') {
|
if (path.join('.') === 'settings.contentTranslation') {
|
||||||
store.account.set('shortcuts', states.shortcuts);
|
store.account.set('settings-contentTranslation', !!value);
|
||||||
|
}
|
||||||
|
if (path.join('.') === 'settings.contentTranslationTargetLanguage') {
|
||||||
|
console.log('SET', value);
|
||||||
|
store.account.set('settings-contentTranslationTargetLanguage', value);
|
||||||
|
}
|
||||||
|
if (path?.[0] === 'shortcuts') {
|
||||||
|
store.account.set('shortcuts', states.shortcuts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -72,6 +86,7 @@ export function hideAllModals() {
|
||||||
states.showCompose = false;
|
states.showCompose = false;
|
||||||
states.showSettings = false;
|
states.showSettings = false;
|
||||||
states.showAccount = false;
|
states.showAccount = false;
|
||||||
|
states.showAccounts = false;
|
||||||
states.showDrafts = false;
|
states.showDrafts = false;
|
||||||
states.showMediaModal = false;
|
states.showMediaModal = false;
|
||||||
states.showShortcutsSettings = false;
|
states.showShortcutsSettings = false;
|
||||||
|
|
Loading…
Reference in a new issue