wip 3-col layout
This commit is contained in:
parent
60af57059b
commit
dd941ae70e
@ -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>
|
||||
);
|
||||
}
|
104
packages/app/src/Pages/Layout/AccountHeader.tsx
Normal file
104
packages/app/src/Pages/Layout/AccountHeader.tsx
Normal 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;
|
30
packages/app/src/Pages/Layout/LogoHeader.tsx
Normal file
30
packages/app/src/Pages/Layout/LogoHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
56
packages/app/src/Pages/Layout/NavSidebar.tsx
Normal file
56
packages/app/src/Pages/Layout/NavSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
packages/app/src/Pages/Layout/RightColumn.tsx
Normal file
11
packages/app/src/Pages/Layout/RightColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
99
packages/app/src/Pages/Layout/index.tsx
Normal file
99
packages/app/src/Pages/Layout/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -39,8 +39,6 @@
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.profile .banner {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
|
@ -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", () => {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -47,6 +47,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user