wip 3-col layout

This commit is contained in:
Martti Malmi 2023-11-21 11:57:25 +02:00
parent 60af57059b
commit dd941ae70e
11 changed files with 302 additions and 218 deletions

View File

@ -1,202 +0,0 @@
import "./Layout.css";
import { useEffect, useMemo, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import Icon from "@/Icons/Icon";
import useLoginFeed from "@/Feed/LoginFeed";
import { mapPlanName } from "./subscribe";
import useLogin from "@/Hooks/useLogin";
import Avatar from "@/Element/User/Avatar";
import { isHalloween, isFormElement, isStPatricksDay, isChristmas } from "@/SnortUtils";
import { getCurrentSubscription } from "@/Subscription";
import Toaster from "@/Toaster";
import { useTheme } from "@/Hooks/useTheme";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import { LoginUnlock } from "@/Element/PinPrompt";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { LoginStore } from "@/Login";
import { NoteCreatorButton } from "@/Element/Event/NoteCreatorButton";
import { ProfileLink } from "@/Element/User/ProfileLink";
import SearchBox from "../Element/SearchBox";
import SnortApi from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher";
export default function Layout() {
const location = useLocation();
const [pageClass, setPageClass] = useState("page");
const { id, stalker } = useLogin(s => ({ id: s.id, stalker: s.stalker ?? false }));
useLoginFeed();
useTheme();
useLoginRelays();
useKeyboardShortcut(".", event => {
// if event happened in a form element, do nothing, otherwise focus on search input
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
});
const shouldHideHeader = useMemo(() => {
const hideOn = ["/login", "/new"];
return hideOn.some(a => location.pathname.startsWith(a));
}, [location]);
useEffect(() => {
const widePage = ["/login", "/messages"];
const noScroll = ["/messages"];
if (widePage.some(a => location.pathname.startsWith(a))) {
setPageClass(noScroll.some(a => location.pathname.startsWith(a)) ? "scroll-lock" : "");
} else {
setPageClass("page");
}
}, [location]);
return (
<>
<div className={pageClass}>
{!shouldHideHeader && (
<header className="main-content">
<LogoHeader />
<AccountHeader />
</header>
)}
<Outlet />
<NoteCreatorButton className="note-create-button" />
<Toaster />
</div>
<LoginUnlock />
{stalker && (
<div
className="stalker"
onClick={() => {
LoginStore.removeSession(id);
}}>
<button type="button" className="circle flex items-center">
<Icon name="close" />
</button>
</div>
)}
</>
);
}
const AccountHeader = () => {
const navigate = useNavigate();
useKeyboardShortcut("/", event => {
// if event happened in a form element, do nothing, otherwise focus on search input
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
document.querySelector<HTMLInputElement>(".search input")?.focus();
}
});
const { publicKey, latestNotification, readNotifications, readonly } = useLogin(s => ({
publicKey: s.publicKey,
latestNotification: s.latestNotification,
readNotifications: s.readNotifications,
readonly: s.readonly,
}));
const profile = useUserProfile(publicKey);
const { publisher } = useEventPublisher();
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
[latestNotification, readNotifications],
);
const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
async function goToNotifications() {
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const api = new SnortApi(undefined, publisher);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
});
await api.registerPushNotifications({
endpoint: sub.endpoint,
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
scope: `${location.protocol}//${location.hostname}`,
});
}
}
} catch (e) {
console.error(e);
}
}
if (!publicKey) {
return (
<button type="button" onClick={() => navigate("/login/sign-up")}>
<FormattedMessage defaultMessage="Sign Up" id="39AHJm" />
</button>
);
}
return (
<div className="header-actions">
{!location.pathname.startsWith("/search") ? <SearchBox /> : <div className="grow"></div>}
{!readonly && (
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</Link>
)}
<Link className="btn" to="/notifications" onClick={goToNotifications}>
<Icon name="bell-02" size={24} />
{hasNotifications && <span className="has-unread"></span>}
</Link>
<ProfileLink pubkey={publicKey} user={profile}>
<Avatar pubkey={publicKey} user={profile} />
</ProfileLink>
</div>
);
};
function LogoHeader() {
const { subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const extra = () => {
if (isHalloween()) return "🎃";
if (isStPatricksDay()) return "🍀";
if (isChristmas()) return "🎄";
};
return (
<Link to="/" className="logo">
<h1>
{extra()}
{CONFIG.appName}
</h1>
{currentSubscription && (
<div className="flex items-center g4 text-sm font-semibold tracking-wider">
<Icon name="diamond" size={16} className="text-pro" />
{mapPlanName(currentSubscription.type)}
</div>
)}
</Link>
);
}

View File

@ -0,0 +1,104 @@
import { Link, useNavigate } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { useMemo } from "react";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
import SearchBox from "../../Element/SearchBox";
import { ProfileLink } from "../../Element/User/ProfileLink";
import Avatar from "../../Element/User/Avatar";
import Icon from "../../Icons/Icon";
import useKeyboardShortcut from "../../Hooks/useKeyboardShortcut";
import { isFormElement } from "../../SnortUtils";
import useLogin from "../../Hooks/useLogin";
import useEventPublisher from "../../Hooks/useEventPublisher";
import SnortApi from "../../External/SnortApi";
const AccountHeader = () => {
const navigate = useNavigate();
useKeyboardShortcut("/", event => {
// if event happened in a form element, do nothing, otherwise focus on search input
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
document.querySelector<HTMLInputElement>(".search input")?.focus();
}
});
const { publicKey, latestNotification, readNotifications, readonly } = useLogin(s => ({
publicKey: s.publicKey,
latestNotification: s.latestNotification,
readNotifications: s.readNotifications,
readonly: s.readonly,
}));
const profile = useUserProfile(publicKey);
const { publisher } = useEventPublisher();
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
[latestNotification, readNotifications],
);
const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
async function goToNotifications() {
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const api = new SnortApi(undefined, publisher);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
});
await api.registerPushNotifications({
endpoint: sub.endpoint,
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
scope: `${location.protocol}//${location.hostname}`,
});
}
}
} catch (e) {
console.error(e);
}
}
if (!publicKey) {
return (
<button type="button" onClick={() => navigate("/login/sign-up")}>
<FormattedMessage defaultMessage="Sign Up" id="39AHJm" />
</button>
);
}
return (
<div className="header-actions">
{!location.pathname.startsWith("/search") ? <SearchBox /> : <div className="grow"></div>}
{!readonly && (
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</Link>
)}
<Link className="btn" to="/notifications" onClick={goToNotifications}>
<Icon name="bell-02" size={24} />
{hasNotifications && <span className="has-unread"></span>}
</Link>
<ProfileLink pubkey={publicKey} user={profile}>
<Avatar pubkey={publicKey} user={profile} />
</ProfileLink>
</div>
);
};
export default AccountHeader;

View File

@ -0,0 +1,30 @@
import useLogin from "../../Hooks/useLogin";
import { getCurrentSubscription } from "../../Subscription";
import { isChristmas, isHalloween, isStPatricksDay } from "../../SnortUtils";
import { Link } from "react-router-dom";
import { mapPlanName } from "../subscribe";
export function LogoHeader() {
const { subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const extra = () => {
if (isHalloween()) return "🎃";
if (isStPatricksDay()) return "🍀";
if (isChristmas()) return "🎄";
};
return (
<Link to="/" className="logo">
<h1>
{extra()}
{CONFIG.appName}
</h1>
{currentSubscription && (
<div className="flex items-center g4 text-sm font-semibold tracking-wider">
<Icon name="diamond" size={16} className="text-pro" />
{mapPlanName(currentSubscription.type)}
</div>
)}
</Link>
);
}

View File

@ -0,0 +1,56 @@
import { LogoHeader } from "./LogoHeader";
import { Link } from "react-router-dom";
import Icon from "@/Icons/Icon";
const MENU_ITEMS = [
{
label: "Home",
icon: "home",
link: "/",
},
{
label: "Messages",
icon: "mail",
link: "/messages",
},
{
label: "Notifications",
icon: "bell-02",
link: "/notifications",
},
{
label: "Settings",
icon: "cog",
link: "/settings",
},
];
export default function NavSidebar() {
return (
<div className="sticky border-r border-neutral-900 top-0 z-20 h-screen max-h-screen hidden md:flex xl:w-56 flex-col px-2 py-4 flex-shrink-0 gap-2">
<LogoHeader />
<div className="flex-grow flex flex-col justify-between">
<div className="flex flex-col gap-2">
{MENU_ITEMS.map(item => (
<NavLink
key={item.link}
to={item.link}
className="flex items-center py-2 px-4 rounded-full text-neutral-100 hover:bg-neutral-800 hover:text-neutral-200 hover:no-underline"
activeClassName="bg-neutral-800 text-neutral-200">
<Icon name={item.icon} size={24} className="mr-2" />
<span className="hidden xl:inline">{item.label}</span>
</NavLink>
))}
</div>
</div>
</div>
);
}
function NavLink({ children, ...props }) {
return (
<Link to={""} {...props}>
{children}
</Link>
);
}

View File

@ -0,0 +1,11 @@
import SearchBox from "../../Element/SearchBox";
export default function RightColumn() {
return (
<div className="flex-col hidden lg:flex lg:w-1/3 sticky top-0 h-screen p-2">
<div>
<SearchBox />
</div>
</div>
);
}

View File

@ -0,0 +1,99 @@
import "./Layout.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Icon from "@/Icons/Icon";
import useLogin from "@/Hooks/useLogin";
import { isFormElement } from "@/SnortUtils";
import Toaster from "@/Toaster";
import { useTheme } from "@/Hooks/useTheme";
import { useLoginRelays } from "@/Hooks/useLoginRelays";
import { LoginUnlock } from "@/Element/PinPrompt";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { LoginStore } from "@/Login";
import { NoteCreatorButton } from "@/Element/Event/NoteCreatorButton";
import NavSidebar from "./NavSidebar";
import AccountHeader from "./AccountHeader";
import RightColumn from "./RightColumn";
import { LogoHeader } from "./LogoHeader";
export default function Index() {
const location = useLocation();
const [pageClass, setPageClass] = useState("page");
const { id, stalker } = useLogin(s => ({ id: s.id, stalker: s.stalker ?? false }));
useTheme();
useLoginRelays();
const hideHeaderPaths = ["/login", "/new"];
const shouldHideHeader = hideHeaderPaths.some(path => location.pathname.startsWith(path));
const pageClassPaths = useMemo(
() => ({
widePage: ["/login", "/messages"],
noScroll: ["/messages"],
}),
[],
);
useEffect(() => {
const isWidePage = pageClassPaths.widePage.some(path => location.pathname.startsWith(path));
const isNoScroll = pageClassPaths.noScroll.some(path => location.pathname.startsWith(path));
setPageClass(isWidePage ? (isNoScroll ? "scroll-lock" : "") : "page");
}, [location, pageClassPaths]);
const handleKeyboardShortcut = useCallback(event => {
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" });
}
}, []);
useKeyboardShortcut(".", handleKeyboardShortcut);
const isStalker = !!stalker;
return (
<div className="h-screen flex justify-center">
<div className={`${pageClass} w-full max-w-screen-xl overflow-x-hidden`}>
<div className="flex flex-row w-full">
<NavSidebar className="w-1/4 flex-shrink-0" />
<MainContent shouldHideHeader={shouldHideHeader} />
<RightColumn className="w-1/4 flex-shrink-0" />
</div>
<NoteCreatorButton className="note-create-button" />
<Toaster />
</div>
<LoginUnlock />
{isStalker && <StalkerModal id={id} />}
</div>
);
}
function MainContent({ shouldHideHeader }) {
return (
<div className="flex flex-1 flex-col overflow-x-hidden">
{!shouldHideHeader && <Header />}
<Outlet />
</div>
);
}
function Header() {
return (
<header className="sticky top-0 md:hidden">
<LogoHeader />
<AccountHeader />
</header>
);
}
function StalkerModal({ id }) {
return (
<div className="stalker" onClick={() => LoginStore.removeSession(id)}>
<button type="button" className="circle flex items-center">
<Icon name="close" />
</button>
</div>
);
}

View File

@ -39,8 +39,6 @@
@media (min-width: 520px) {
.profile .banner {
width: 100%;
max-width: 720px;
height: 280px;
}

View File

@ -1,5 +1,5 @@
import { magnetURIDecode, getRelayName } from ".";
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
describe("magnet", () => {
it("should parse magnet link", () => {

View File

@ -136,19 +136,7 @@ a.ext {
overflow-x: hidden;
}
.page {
width: 100vw;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 768px) {
.page {
width: 640px;
margin-left: auto;
margin-right: auto;
}
.main-content {
border: 1px solid var(--border-color);
}

View File

@ -47,6 +47,6 @@ export default defineConfig({
},
test: {
globals: true,
environment: 'jsdom',
environment: "jsdom",
},
});