mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-21 16:55:25 +03:00
Replace preact-router with react-router
Need more routing powers, hopefully things don't break 🤞
This commit is contained in:
parent
baf139762c
commit
9bff95bcec
15 changed files with 662 additions and 362 deletions
|
@ -62,6 +62,7 @@ Prerequisites: Node.js 18+
|
|||
- [Vite](https://vitejs.dev/) - Build tool
|
||||
- [Preact](https://preactjs.com/) - UI library
|
||||
- [Valtio](https://valtio.pmnd.rs/) - State management
|
||||
- [React Router](https://reactrouter.com/) - Routing
|
||||
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
||||
- [Iconify](https://iconify.design/) - Icon library
|
||||
- Vanilla CSS - *Yes, I'm old school.*
|
||||
|
|
101
package-lock.json
generated
101
package-lock.json
generated
|
@ -14,15 +14,14 @@
|
|||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-deep-equal": "~3.1.3",
|
||||
"history": "~5.3.0",
|
||||
"idb-keyval": "~6.2.0",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~5.5.0",
|
||||
"mem": "~9.0.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
"react-hotkeys-hook": "~4.3.2",
|
||||
"react-intersection-observer": "~9.4.1",
|
||||
"react-router-dom": "~6.7.0",
|
||||
"string-length": "~5.0.1",
|
||||
"swiped-events": "~1.1.7",
|
||||
"toastify-js": "~1.12.0",
|
||||
|
@ -1653,6 +1652,7 @@
|
|||
"version": "7.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz",
|
||||
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
},
|
||||
|
@ -2276,6 +2276,14 @@
|
|||
"vite": ">=2.0.0-beta.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0.tgz",
|
||||
"integrity": "sha512-nwQoYb3m4DDpHTeOwpJEuDt8lWVcujhYYSFGLluC+9es2PyLjm+jjq3IeRBQbwBtPLJE/lkuHuGHr8uQLgmJRA==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-replace": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz",
|
||||
|
@ -3607,14 +3615,6 @@
|
|||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/history": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
|
||||
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.6"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
|
@ -4537,14 +4537,6 @@
|
|||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-router": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.0.tgz",
|
||||
"integrity": "sha512-y1w2YvVpKAju9FMV+fAVR1NpH4MW5q07BZrziMZeg6F/rGJ9KvLUZtjOqsy2I8fDYiX36AM1AQTXIIK3jigBhA==",
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
|
||||
|
@ -4672,6 +4664,36 @@
|
|||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.7.0.tgz",
|
||||
"integrity": "sha512-KNWlG622ddq29MAM159uUsNMdbX8USruoKnwMMQcs/QWZgFUayICSn2oB7reHce1zPj6CG18kfkZIunSSRyGHg==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.7.0.tgz",
|
||||
"integrity": "sha512-jQtXUJyhso3kFw430+0SPCbmCmY1/kJv8iRffGHwHy3CkoomGxeYzMkmeSPYo6Egzh3FKJZRAL22yg5p2tXtfg==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.3.0",
|
||||
"react-router": "6.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
|
@ -4693,7 +4715,8 @@
|
|||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/regenerator-transform": {
|
||||
"version": "0.15.1",
|
||||
|
@ -7013,6 +7036,7 @@
|
|||
"version": "7.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz",
|
||||
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
}
|
||||
|
@ -7394,6 +7418,11 @@
|
|||
"@rollup/pluginutils": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"@remix-run/router": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0.tgz",
|
||||
"integrity": "sha512-nwQoYb3m4DDpHTeOwpJEuDt8lWVcujhYYSFGLluC+9es2PyLjm+jjq3IeRBQbwBtPLJE/lkuHuGHr8uQLgmJRA=="
|
||||
},
|
||||
"@rollup/plugin-replace": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz",
|
||||
|
@ -8413,14 +8442,6 @@
|
|||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
|
||||
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.7.6"
|
||||
}
|
||||
},
|
||||
"idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
|
@ -9097,12 +9118,6 @@
|
|||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
||||
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="
|
||||
},
|
||||
"preact-router": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.0.tgz",
|
||||
"integrity": "sha512-y1w2YvVpKAju9FMV+fAVR1NpH4MW5q07BZrziMZeg6F/rGJ9KvLUZtjOqsy2I8fDYiX36AM1AQTXIIK3jigBhA==",
|
||||
"requires": {}
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
|
||||
|
@ -9181,6 +9196,23 @@
|
|||
"integrity": "sha512-IXpIsPe6BleFOEHKzKh5UjwRUaz/JYS0lT/HPsupWEQou2hDqjhLMStc5zyE3eQVT4Fk3FufM8Fw33qW1uyeiw==",
|
||||
"requires": {}
|
||||
},
|
||||
"react-router": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.7.0.tgz",
|
||||
"integrity": "sha512-KNWlG622ddq29MAM159uUsNMdbX8USruoKnwMMQcs/QWZgFUayICSn2oB7reHce1zPj6CG18kfkZIunSSRyGHg==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.3.0"
|
||||
}
|
||||
},
|
||||
"react-router-dom": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.7.0.tgz",
|
||||
"integrity": "sha512-jQtXUJyhso3kFw430+0SPCbmCmY1/kJv8iRffGHwHy3CkoomGxeYzMkmeSPYo6Egzh3FKJZRAL22yg5p2tXtfg==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.3.0",
|
||||
"react-router": "6.7.0"
|
||||
}
|
||||
},
|
||||
"regenerate": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
|
@ -9199,7 +9231,8 @@
|
|||
"regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"dev": true
|
||||
},
|
||||
"regenerator-transform": {
|
||||
"version": "0.15.1",
|
||||
|
|
|
@ -16,15 +16,14 @@
|
|||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-deep-equal": "~3.1.3",
|
||||
"history": "~5.3.0",
|
||||
"idb-keyval": "~6.2.0",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~5.5.0",
|
||||
"mem": "~9.0.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
"react-hotkeys-hook": "~4.3.2",
|
||||
"react-intersection-observer": "~9.4.1",
|
||||
"react-router-dom": "~6.7.0",
|
||||
"string-length": "~5.0.1",
|
||||
"swiped-events": "~1.1.7",
|
||||
"toastify-js": "~1.12.0",
|
||||
|
|
88
src/app.css
88
src/app.css
|
@ -46,6 +46,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transition: opacity 0.1s ease-in-out;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
.deck-container[hidden] {
|
||||
display: block;
|
||||
|
@ -61,6 +62,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
scroll-padding-top: 3em;
|
||||
}
|
||||
|
||||
.deck-container {
|
||||
transition: transform 0.4s var(--timing-function);
|
||||
}
|
||||
.deck-container:has(~ .deck-backdrop) {
|
||||
transition: transform 0.4s ease-out;
|
||||
transform: translate3d(-5vw, 0, 0);
|
||||
}
|
||||
|
||||
.deck {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
|
@ -364,7 +373,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
-webkit-tap-highlight-color: transparent;
|
||||
animation: appear 0.2s ease-out;
|
||||
}
|
||||
.status-link:is(:hover, :focus) {
|
||||
.status-link:is(:hover, :focus, .is-active) {
|
||||
background-color: var(--link-bg-hover-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
@ -508,11 +517,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
max-width: 40em;
|
||||
}
|
||||
|
||||
.decks {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deck-close {
|
||||
color: var(--text-insignificant-color) !important;
|
||||
}
|
||||
|
@ -944,21 +948,63 @@ meter.donut:is(.danger, .explode):after {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.deck-container {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
#home-page ~ .deck-container {
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
#home-page:has(~ .deck-container) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
content-visibility: hidden;
|
||||
}
|
||||
|
||||
/* TAB BAR */
|
||||
|
||||
#tab-bar:not([hidden]) {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
width: calc(100% - 32px);
|
||||
max-width: calc(40em - 32px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(16px) saturate(3);
|
||||
border: var(--hairline-width) solid var(--outline-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px var(--outline-color);
|
||||
}
|
||||
#tab-bar li {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
#tab-bar li a {
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 40em) {
|
||||
html,
|
||||
body {
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
.deck-container {
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
}
|
||||
.decks {
|
||||
transition: transform 0.4s var(--timing-function);
|
||||
}
|
||||
.decks:has(~ .deck-backdrop) {
|
||||
transition: transform 0.4s ease-out;
|
||||
transform: translate3d(-5vw, 0, 0);
|
||||
}
|
||||
.deck-backdrop .deck {
|
||||
width: 50%;
|
||||
min-width: 40em;
|
||||
|
@ -995,6 +1041,22 @@ meter.donut:is(.danger, .explode):after {
|
|||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 1px var(--bg-blur-color);
|
||||
transition: transform 0.4s var(--timing-function);
|
||||
--back-transition: transform 0.4s ease-out;
|
||||
}
|
||||
.timeline-deck .timeline:not(.flat) > li:has(.status-link.is-active) {
|
||||
transition: var(--back-transition);
|
||||
transform: translate3d(-2.5vw, 0, 0);
|
||||
}
|
||||
.timeline-deck
|
||||
.timeline:not(.flat)
|
||||
> li:not(:has(.boost-carousel)):has(+ li .status-link.is-active),
|
||||
.timeline-deck
|
||||
.timeline:not(.flat)
|
||||
> li:not(:has(.boost-carousel)):has(.status-link.is-active)
|
||||
+ li {
|
||||
transition: var(--back-transition);
|
||||
transform: translate3d(-1.25vw, 0, 0);
|
||||
}
|
||||
.box {
|
||||
padding: 32px;
|
||||
|
|
120
src/app.jsx
120
src/app.jsx
|
@ -1,19 +1,21 @@
|
|||
import './app.css';
|
||||
import 'toastify-js/src/toastify.css';
|
||||
|
||||
import { createHashHistory } from 'history';
|
||||
import debounce from 'just-debounce-it';
|
||||
import { login } from 'masto';
|
||||
import Router, { route } from 'preact-router';
|
||||
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Toastify from 'toastify-js';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Account from './components/account';
|
||||
import Compose from './components/compose';
|
||||
import Drafts from './components/drafts';
|
||||
import Icon from './components/icon';
|
||||
import Link from './components/link';
|
||||
import Loader from './components/loader';
|
||||
import Modal from './components/modal';
|
||||
import Bookmarks from './pages/bookmarks';
|
||||
import Home from './pages/home';
|
||||
import Login from './pages/login';
|
||||
import Notifications from './pages/notifications';
|
||||
|
@ -24,14 +26,13 @@ import { getAccessToken } from './utils/auth';
|
|||
import states, { saveStatus } from './utils/states';
|
||||
import store from './utils/store';
|
||||
|
||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||
|
||||
window.__STATES__ = states;
|
||||
|
||||
function App() {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const theme = store.local.get('theme');
|
||||
|
@ -126,20 +127,22 @@ function App() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const [currentDeck, setCurrentDeck] = useState('home');
|
||||
const [currentModal, setCurrentModal] = useState(null);
|
||||
let location = useLocation();
|
||||
const locationDeckMap = {
|
||||
'/': 'home-page',
|
||||
'/notifications': 'notifications-page',
|
||||
};
|
||||
const focusDeck = () => {
|
||||
if (currentModal) return;
|
||||
let timer = setTimeout(() => {
|
||||
const page = document.getElementById(`${currentDeck}-page`);
|
||||
console.debug('FOCUS', currentDeck, page);
|
||||
const page = document.getElementById(locationDeckMap[location.pathname]);
|
||||
console.debug('FOCUS', location.pathname, page);
|
||||
if (page) {
|
||||
page.focus();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
useEffect(focusDeck, [currentDeck, currentModal]);
|
||||
useEffect(focusDeck, [location]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!snapStates.showCompose &&
|
||||
|
@ -173,44 +176,66 @@ function App() {
|
|||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
const backgroundLocation = useMemo(() => {
|
||||
const { prevLocation } = snapStates;
|
||||
|
||||
console.debug({ location, prevLocation });
|
||||
const { pathname } = location;
|
||||
const { pathname: prevPathname } = prevLocation || {};
|
||||
console.debug({ prevPathname, pathname });
|
||||
const isModalPage = /^\/s\//i.test(pathname);
|
||||
return isModalPage ? prevLocation : null;
|
||||
}, [location]);
|
||||
|
||||
const nonRootLocation = useMemo(() => {
|
||||
const { pathname } = location;
|
||||
return !/\/(login|welcome)$/.test(pathname);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoggedIn && currentDeck && (
|
||||
<div class="decks">
|
||||
{/* Home will never be unmounted */}
|
||||
<Home hidden={currentDeck !== 'home'} />
|
||||
{/* Notifications can be unmounted */}
|
||||
{currentDeck === 'notifications' && <Notifications />}
|
||||
</div>
|
||||
)}
|
||||
{!isLoggedIn && uiState === 'loading' && <Loader />}
|
||||
<Router
|
||||
history={createHashHistory()}
|
||||
onChange={(e) => {
|
||||
console.debug('ROUTER onChange', e);
|
||||
// Special handling for Home and Notifications
|
||||
const { url } = e;
|
||||
if (/notifications/i.test(url)) {
|
||||
setCurrentDeck('notifications');
|
||||
setCurrentModal(null);
|
||||
} else if (url === '/') {
|
||||
setCurrentDeck('home');
|
||||
document.title = `Home / ${CLIENT_NAME}`;
|
||||
setCurrentModal(null);
|
||||
} else if (/^\/s\//i.test(url)) {
|
||||
setCurrentModal('status');
|
||||
} else {
|
||||
setCurrentModal(null);
|
||||
setCurrentDeck(null);
|
||||
<Routes location={nonRootLocation || location}>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
isLoggedIn ? (
|
||||
<Home />
|
||||
) : uiState === 'loading' ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<Welcome />
|
||||
)
|
||||
}
|
||||
states.history.push(url);
|
||||
}}
|
||||
>
|
||||
{!isLoggedIn && uiState !== 'loading' && <Welcome path="/" />}
|
||||
<Welcome path="/welcome" />
|
||||
{isLoggedIn && <Status path="/s/:id" />}
|
||||
<Login path="/login" />
|
||||
</Router>
|
||||
/>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
</Routes>
|
||||
<Routes location={backgroundLocation || location}>
|
||||
{isLoggedIn && (
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
)}
|
||||
{isLoggedIn && <Route path="/bookmarks" element={<Bookmarks />} />}
|
||||
</Routes>
|
||||
<Routes>
|
||||
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
|
||||
</Routes>
|
||||
<nav id="tab-bar" hidden>
|
||||
<li>
|
||||
<Link to="/">
|
||||
<Icon icon="home" alt="Home" size="xl" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/notifications">
|
||||
<Icon icon="notification" alt="Notifications" size="xl" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/bookmarks">
|
||||
<Icon icon="bookmark" alt="Bookmarks" size="xl" />
|
||||
</Link>
|
||||
</li>
|
||||
</nav>
|
||||
{!!snapStates.showCompose && (
|
||||
<Modal>
|
||||
<Compose
|
||||
|
@ -244,7 +269,8 @@ function App() {
|
|||
// destination: `/#/s/${newStatus.id}`,
|
||||
onClick: () => {
|
||||
toast.hideToast();
|
||||
route(`/s/${newStatus.id}`);
|
||||
states.prevLocation = location;
|
||||
navigate(`/s/${newStatus.id}`);
|
||||
},
|
||||
});
|
||||
toast.showToast();
|
||||
|
|
30
src/components/link.jsx
Normal file
30
src/components/link.jsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import states from '../utils/states';
|
||||
|
||||
/* NOTES
|
||||
=====
|
||||
Initially this uses <NavLink> from react-router-dom, but it doesn't work:
|
||||
1. It interferes with nested <a> inside <a> and it's difficult to preventDefault/stopPropagation from the nested <a>
|
||||
2. isActive doesn't work properly with the weird routes that's set up in this app, due to the faux "location" to make the modals work and prevent unmounting
|
||||
3. Not using <Link state/> because it modifies history.state that *persists* across page reloads. I don't need that, so using valtio's states instead.
|
||||
*/
|
||||
|
||||
const Link = (props) => {
|
||||
const routerLocation = useLocation();
|
||||
let hash = (location.hash || '').replace(/^#/, '').trim();
|
||||
if (hash === '') hash = '/';
|
||||
const isActive = hash === props.to;
|
||||
return (
|
||||
<a
|
||||
href={`#${props.to}`}
|
||||
{...props}
|
||||
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
states.prevLocation = routerLocation;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Link;
|
|
@ -29,6 +29,7 @@ import visibilityIconsMap from '../utils/visibility-icons-map';
|
|||
|
||||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import RelativeTime from './relative-time';
|
||||
|
||||
function fetchAccount(id) {
|
||||
|
@ -251,18 +252,14 @@ function Status({
|
|||
{/* </span> */}{' '}
|
||||
{size !== 'l' &&
|
||||
(uri ? (
|
||||
<a
|
||||
href={`#/s/${id}
|
||||
`}
|
||||
class="time"
|
||||
>
|
||||
<Link to={`/s/${id}`} class="time">
|
||||
<Icon
|
||||
icon={visibilityIconsMap[visibility]}
|
||||
alt={visibility}
|
||||
size="s"
|
||||
/>{' '}
|
||||
<RelativeTime datetime={createdAtDate} format="micro" />
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span class="time">
|
||||
<Icon
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import './index.css';
|
||||
|
||||
import { render } from 'preact';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
|
@ -8,7 +9,12 @@ if (import.meta.env.DEV) {
|
|||
import('preact/debug');
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app'));
|
||||
render(
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>,
|
||||
document.getElementById('app'),
|
||||
);
|
||||
|
||||
// Clean up iconify localStorage
|
||||
// TODO: Remove this after few weeks?
|
||||
|
|
144
src/pages/bookmarks.jsx
Normal file
144
src/pages/bookmarks.jsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import Icon from '../components/Icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/Loader';
|
||||
import Status from '../components/status';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 40;
|
||||
|
||||
function Bookmarks() {
|
||||
useTitle('Bookmarks');
|
||||
const [bookmarks, setBookmarks] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
const bookmarksIterator = useRef(masto.v1.bookmarks.list({ limit: LIMIT }));
|
||||
async function fetchBookmarks(firstLoad) {
|
||||
console.log('fetchBookmarks', firstLoad);
|
||||
if (firstLoad) {
|
||||
bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT });
|
||||
}
|
||||
const allBookmarks = await bookmarksIterator.current.next();
|
||||
if (allBookmarks.value?.length) {
|
||||
const bookmarksValue = allBookmarks.value.map((status) => {
|
||||
saveStatus(status, {
|
||||
skipThreading: true,
|
||||
override: false,
|
||||
});
|
||||
return status;
|
||||
});
|
||||
if (firstLoad) {
|
||||
setBookmarks(bookmarksValue);
|
||||
} else {
|
||||
setBookmarks([...bookmarks, ...bookmarksValue]);
|
||||
}
|
||||
}
|
||||
return allBookmarks;
|
||||
}
|
||||
|
||||
const loadBookmarks = (firstLoad) => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
console.log('loadBookmarks', firstLoad);
|
||||
const { done } = await fetchBookmarks(firstLoad);
|
||||
console.log('loadBookmarks', firstLoad);
|
||||
setShowMore(!done);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBookmarks(true);
|
||||
}, []);
|
||||
|
||||
const scrollableRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="bookmarks-page"
|
||||
class="deck-container"
|
||||
ref={scrollableRef}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div class="timeline-deck deck">
|
||||
<header
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
scrollableRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="header-side">
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</Link>
|
||||
</div>
|
||||
<h1>Bookmarks</h1>
|
||||
<div class="header-side"></div>{' '}
|
||||
</header>
|
||||
{!!bookmarks.length ? (
|
||||
<>
|
||||
<ul class="timeline">
|
||||
{bookmarks.map((status) => (
|
||||
<li key={`bookmark-${status.id}`}>
|
||||
<Link class="status-link" to={`/s/${status.id}`}>
|
||||
<Status status={status} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{showMore && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain block"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => loadBookmarks()}
|
||||
style={{ marginBlockEnd: '6em' }}
|
||||
>
|
||||
{uiState === 'loading' ? <Loader /> : <>Show more…</>}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
uiState !== 'loading' && (
|
||||
<p class="ui-state">No bookmarks yet. Go bookmark something!</p>
|
||||
)
|
||||
)}
|
||||
{uiState === 'loading' ? (
|
||||
<div class="ui-state">
|
||||
<Loader />
|
||||
</div>
|
||||
) : uiState === 'error' ? (
|
||||
<p class="ui-state">
|
||||
Unable to load bookmarks.
|
||||
<br />
|
||||
<br />
|
||||
<button
|
||||
class="button plain"
|
||||
onClick={() => loadBookmarks(!bookmarks.length)}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
bookmarks.length &&
|
||||
!showMore && <p class="ui-state insignificant">The end.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Bookmarks;
|
|
@ -1,10 +1,10 @@
|
|||
import { Link } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import Status from '../components/status';
|
||||
import db from '../utils/db';
|
||||
|
@ -36,9 +36,7 @@ function Home({ hidden }) {
|
|||
states.homeNew = [];
|
||||
}
|
||||
const allStatuses = await homeIterator.current.next();
|
||||
if (allStatuses.value <= 0) {
|
||||
return { done: true };
|
||||
}
|
||||
if (allStatuses.value?.length) {
|
||||
const homeValues = allStatuses.value.map((status) => {
|
||||
saveStatus(status);
|
||||
return {
|
||||
|
@ -107,11 +105,10 @@ function Home({ hidden }) {
|
|||
states.home.push(...homeValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
states.homeLastFetchTime = Date.now();
|
||||
return {
|
||||
done: false,
|
||||
};
|
||||
return allStatuses;
|
||||
}
|
||||
|
||||
const loadingStatuses = useRef(false);
|
||||
|
@ -276,6 +273,7 @@ function Home({ hidden }) {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id="home-page"
|
||||
class="deck-container"
|
||||
|
@ -283,24 +281,6 @@ function Home({ hidden }) {
|
|||
ref={scrollableRef}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<button
|
||||
hidden={scrollDirection === 'end' && !nearReachStart}
|
||||
type="button"
|
||||
id="compose-button"
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
const newWin = openCompose();
|
||||
if (!newWin) {
|
||||
alert('Looks like your browser is blocking popups.');
|
||||
states.showCompose = true;
|
||||
}
|
||||
} else {
|
||||
states.showCompose = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="quill" size="xxl" alt="Compose" />
|
||||
</button>
|
||||
<div class="timeline-deck deck">
|
||||
<header
|
||||
hidden={scrollDirection === 'end' && !nearReachStart}
|
||||
|
@ -327,8 +307,8 @@ function Home({ hidden }) {
|
|||
<h1>Home</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />{' '}
|
||||
<a
|
||||
href="#/notifications"
|
||||
<Link
|
||||
to="/notifications"
|
||||
class={`button plain ${
|
||||
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
|
||||
}`}
|
||||
|
@ -337,7 +317,7 @@ function Home({ hidden }) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="notification" size="l" alt="Notifications" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
{snapStates.homeNew.length > 0 &&
|
||||
|
@ -380,11 +360,7 @@ function Home({ hidden }) {
|
|||
}
|
||||
return (
|
||||
<li key={statusID}>
|
||||
<Link
|
||||
activeClassName="active"
|
||||
class="status-link"
|
||||
href={`#/s/${actualStatusID}`}
|
||||
>
|
||||
<Link class="status-link" to={`/s/${actualStatusID}`}>
|
||||
<Status statusID={statusID} />
|
||||
</Link>
|
||||
</li>
|
||||
|
@ -452,6 +428,25 @@ function Home({ hidden }) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
hidden={scrollDirection === 'end' && !nearReachStart}
|
||||
type="button"
|
||||
id="compose-button"
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
const newWin = openCompose();
|
||||
if (!newWin) {
|
||||
alert('Looks like your browser is blocking popups.');
|
||||
states.showCompose = true;
|
||||
}
|
||||
} else {
|
||||
states.showCompose = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="quill" size="xxl" alt="Compose" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -504,9 +499,9 @@ function BoostsCarousel({ boosts }) {
|
|||
const actualStatusID = reblog || statusID;
|
||||
return (
|
||||
<li>
|
||||
<a class="status-boost-link" href={`#/s/${actualStatusID}`}>
|
||||
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
|
||||
<Status statusID={statusID} size="s" />
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -2,6 +2,7 @@ import './login.css';
|
|||
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import instancesListURL from '../data/instances.json?url';
|
||||
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
||||
|
@ -111,7 +112,7 @@ function Login() {
|
|||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/#">Go home</a>
|
||||
<Link to="/">Go home</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import './notifications.css';
|
||||
|
||||
import { Link } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Avatar from '../components/avatar';
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import NameText from '../components/name-text';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
import Status from '../components/status';
|
||||
import states from '../utils/states';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
|
@ -156,7 +156,7 @@ function Notification({ notification }) {
|
|||
{status && (
|
||||
<Link
|
||||
class={`status-link status-type-${type}`}
|
||||
href={`#/s/${actualStatusID}`}
|
||||
to={`/s/${actualStatusID}`}
|
||||
>
|
||||
<Status status={status} size="s" />
|
||||
</Link>
|
||||
|
@ -232,13 +232,12 @@ function Notifications() {
|
|||
states.notificationsNew = [];
|
||||
}
|
||||
const allNotifications = await notificationsIterator.current.next();
|
||||
if (allNotifications.value <= 0) {
|
||||
return { done: true };
|
||||
}
|
||||
if (allNotifications.value?.length) {
|
||||
const notificationsValues = allNotifications.value.map((notification) => {
|
||||
if (notification.status) {
|
||||
states.statuses[notification.status.id] = notification.status;
|
||||
}
|
||||
saveStatus(notification.status, {
|
||||
skipThreading: true,
|
||||
override: false,
|
||||
});
|
||||
return notification;
|
||||
});
|
||||
if (firstLoad) {
|
||||
|
@ -246,6 +245,7 @@ function Notifications() {
|
|||
} else {
|
||||
states.notifications.push(...notificationsValues);
|
||||
}
|
||||
}
|
||||
states.notificationsLastFetchTime = Date.now();
|
||||
return allNotifications;
|
||||
}
|
||||
|
@ -310,9 +310,9 @@ function Notifications() {
|
|||
}}
|
||||
>
|
||||
<div class="header-side">
|
||||
<a href="#" class="button plain">
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<h1>Notifications</h1>
|
||||
<div class="header-side">
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useSnapshot } from 'valtio';
|
|||
|
||||
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 states from '../utils/states';
|
||||
|
@ -124,9 +125,9 @@ function Settings({ onClose }) {
|
|||
</p>
|
||||
)}
|
||||
<p style={{ textAlign: 'end' }}>
|
||||
<a href="/#/login" class="button" onClick={onClose}>
|
||||
<Link to="/login" class="button" onClick={onClose}>
|
||||
Add new account
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
<h2>Settings</h2>
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import './status.css';
|
||||
|
||||
import debounce from 'just-debounce-it';
|
||||
import { Link } from 'preact-router/match';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import NameText from '../components/name-text';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
|
@ -23,7 +24,9 @@ import useTitle from '../utils/useTitle';
|
|||
|
||||
const LIMIT = 40;
|
||||
|
||||
function StatusPage({ id }) {
|
||||
function StatusPage() {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const snapStates = useSnapshot(states);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
@ -270,10 +273,11 @@ function StatusPage({ id }) {
|
|||
: 'Status',
|
||||
);
|
||||
|
||||
const prevRoute = states.history.findLast((h) => {
|
||||
return h === '/' || /notifications/i.test(h);
|
||||
});
|
||||
const closeLink = `#${prevRoute || '/'}`;
|
||||
const closeLink = useMemo(() => {
|
||||
const pathname = snapStates.prevLocation?.pathname;
|
||||
if (!pathname || pathname.startsWith('/s/')) return '/';
|
||||
return pathname;
|
||||
}, []);
|
||||
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
const showMore = useMemo(() => {
|
||||
|
@ -305,7 +309,7 @@ function StatusPage({ id }) {
|
|||
|
||||
return (
|
||||
<div class="deck-backdrop">
|
||||
<Link href={closeLink}></Link>
|
||||
<Link to={closeLink}></Link>
|
||||
<div
|
||||
tabIndex="-1"
|
||||
ref={scrollableRef}
|
||||
|
@ -383,7 +387,7 @@ function StatusPage({ id }) {
|
|||
</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<Link class="button plain deck-close" href={closeLink}>
|
||||
<Link class="button plain deck-close" to={closeLink}>
|
||||
<Icon icon="x" size="xl" />
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -420,7 +424,7 @@ function StatusPage({ id }) {
|
|||
class="
|
||||
status-link
|
||||
"
|
||||
href={`#/s/${statusID}`}
|
||||
to={`/s/${statusID}`}
|
||||
>
|
||||
<Status
|
||||
statusID={statusID}
|
||||
|
@ -551,7 +555,7 @@ function SubComments({
|
|||
<li key={r.id}>
|
||||
<Link
|
||||
class="status-link"
|
||||
href={`#/s/${r.id}`}
|
||||
to={`/s/${r.id}`}
|
||||
onClick={onStatusLinkClick}
|
||||
>
|
||||
<Status statusID={r.id} withinContext size="s" />
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import './welcome.css';
|
||||
|
||||
import logo from '../assets/logo.svg';
|
||||
import Link from '../components/link';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
function Welcome() {
|
||||
|
@ -28,9 +29,9 @@ function Welcome() {
|
|||
<p>
|
||||
<big>
|
||||
<b>
|
||||
<a href="#/login" class="button">
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</a>
|
||||
</Link>
|
||||
</b>
|
||||
</big>
|
||||
</p>
|
||||
|
|
Loading…
Reference in a new issue