mobile footer
This commit is contained in:
@ -1,20 +0,0 @@
|
|||||||
.note-create-button {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
color: white;
|
|
||||||
background: linear-gradient(90deg, rgba(239, 150, 68, 1) 0%, rgba(123, 65, 246, 1) 100%);
|
|
||||||
border: none;
|
|
||||||
border-radius: 100%;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 40px;
|
|
||||||
right: calc(((100vw - 640px) / 2) - 75px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.note-create-button {
|
|
||||||
right: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,3 @@
|
|||||||
import "./NoteCreatorButton.css";
|
|
||||||
import { useRef, useMemo } from "react";
|
import { useRef, useMemo } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
74
packages/app/src/Pages/Layout/Footer.tsx
Normal file
74
packages/app/src/Pages/Layout/Footer.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import useLogin from "@/Hooks/useLogin";
|
||||||
|
import Icon from "@/Icons/Icon";
|
||||||
|
import { ProfileLink } from "@/Element/User/ProfileLink";
|
||||||
|
import { NoteCreatorButton } from "@/Element/Event/Create/NoteCreatorButton";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useUserProfile } from "@snort/system-react";
|
||||||
|
import Avatar from "@/Element/User/Avatar";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
|
const MENU_ITEMS = [
|
||||||
|
{ url: "/", icon: "home" },
|
||||||
|
{ url: "/messages", icon: "mail" },
|
||||||
|
{
|
||||||
|
el: (
|
||||||
|
<div className="flex flex-grow items-center justify-center">
|
||||||
|
<NoteCreatorButton alwaysShow={true} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ url: "/search", icon: "search" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const { publicKey, readonly } = useLogin(s => ({
|
||||||
|
publicKey: s.publicKey,
|
||||||
|
readonly: s.readonly,
|
||||||
|
}));
|
||||||
|
const profile = useUserProfile(publicKey);
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
|
const renderButton = item => {
|
||||||
|
if (item.el) {
|
||||||
|
return item.el;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={item.url}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
classNames(
|
||||||
|
{ "text-nostr-purple": isActive, "hover:text-nostr-purple": !isActive },
|
||||||
|
"flex flex-grow p-2 justify-center items-center cursor-pointer",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
<Icon name={item.icon} width={24} />
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOnlyIcon = readonly && (
|
||||||
|
<span style={{ transform: "rotate(135deg)" }} title={formatMessage({ defaultMessage: "Read-only", id: "djNL6D" })}>
|
||||||
|
<Icon name="openeye" className="text-nostr-red" size={20} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="md:hidden fixed bottom-0 z-10 w-full bg-base-200 pb-safe-area bg-bg-color">
|
||||||
|
<div className="flex">
|
||||||
|
{MENU_ITEMS.map(item => renderButton(item))}
|
||||||
|
{publicKey && (
|
||||||
|
<ProfileLink
|
||||||
|
className="flex flex-grow p-2 justify-center items-center cursor-pointer"
|
||||||
|
pubkey={publicKey}
|
||||||
|
user={profile}>
|
||||||
|
<Avatar pubkey={publicKey} user={profile} icons={readOnlyIcon} size={40} />
|
||||||
|
</ProfileLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
27
packages/app/src/Pages/Layout/HasNotificationsMarker.tsx
Normal file
27
packages/app/src/Pages/Layout/HasNotificationsMarker.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import useLogin from "@/Hooks/useLogin";
|
||||||
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { Notifications } from "@/Cache";
|
||||||
|
|
||||||
|
export function HasNotificationsMarker() {
|
||||||
|
const readNotifications = useLogin(s => s.readNotifications);
|
||||||
|
const notifications = useSyncExternalStore(
|
||||||
|
c => Notifications.hook(c, "*"),
|
||||||
|
() => Notifications.snapshot(),
|
||||||
|
);
|
||||||
|
const latestNotification = useMemo(
|
||||||
|
() => notifications.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
|
||||||
|
[notifications],
|
||||||
|
);
|
||||||
|
const hasNotifications = useMemo(
|
||||||
|
() => latestNotification * 1000 > readNotifications,
|
||||||
|
[notifications, readNotifications],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasNotifications) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<span className="has-unread absolute top-0 right-0 rounded-full"></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -15,25 +15,6 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions .avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .btn {
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-unread {
|
.has-unread {
|
||||||
background: var(--highlight);
|
background: var(--highlight);
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
|
@ -8,8 +8,8 @@ import { useUserProfile } from "@snort/system-react";
|
|||||||
import { NoteCreatorButton } from "../../Element/Event/Create/NoteCreatorButton";
|
import { NoteCreatorButton } from "../../Element/Event/Create/NoteCreatorButton";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { HasNotificationsMarker } from "@/Pages/Layout/AccountHeader";
|
|
||||||
import { getCurrentSubscription } from "@/Subscription";
|
import { getCurrentSubscription } from "@/Subscription";
|
||||||
|
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
|
||||||
|
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
{
|
{
|
||||||
|
@ -1,23 +1,17 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
|
||||||
import { useMemo, useSyncExternalStore } from "react";
|
|
||||||
import { base64 } from "@scure/base";
|
import { base64 } from "@scure/base";
|
||||||
import { unwrap } from "@snort/shared";
|
import { unwrap } from "@snort/shared";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
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 Icon from "@/Icons/Icon";
|
||||||
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
|
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
|
||||||
import { isFormElement } from "@/SnortUtils";
|
import { isFormElement } from "@/SnortUtils";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||||
import SnortApi from "@/External/SnortApi";
|
import SnortApi from "@/External/SnortApi";
|
||||||
import { Notifications } from "@/Cache";
|
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
|
||||||
|
|
||||||
const AccountHeader = () => {
|
const NotificationsHeader = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
|
|
||||||
useKeyboardShortcut("/", event => {
|
useKeyboardShortcut("/", event => {
|
||||||
// if event happened in a form element, do nothing, otherwise focus on search input
|
// if event happened in a form element, do nothing, otherwise focus on search input
|
||||||
@ -27,15 +21,11 @@ const AccountHeader = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { publicKey, readonly } = useLogin(s => ({
|
const { publicKey } = useLogin(s => ({
|
||||||
publicKey: s.publicKey,
|
publicKey: s.publicKey,
|
||||||
readonly: s.readonly,
|
|
||||||
}));
|
}));
|
||||||
const profile = useUserProfile(publicKey);
|
|
||||||
const { publisher } = useEventPublisher();
|
const { publisher } = useEventPublisher();
|
||||||
|
|
||||||
const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
|
|
||||||
|
|
||||||
async function goToNotifications() {
|
async function goToNotifications() {
|
||||||
// request permissions to send notifications
|
// request permissions to send notifications
|
||||||
if ("Notification" in window) {
|
if ("Notification" in window) {
|
||||||
@ -78,54 +68,14 @@ const AccountHeader = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const readOnlyIcon = readonly && (
|
|
||||||
<span style={{ transform: "rotate(135deg)" }} title={formatMessage({ defaultMessage: "Read-only", id: "djNL6D" })}>
|
|
||||||
<Icon name="openeye" className="text-nostr-red" size={20} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-actions">
|
<div className="flex justify-between">
|
||||||
{!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}>
|
<Link className="btn" to="/notifications" onClick={goToNotifications}>
|
||||||
<Icon name="bell-02" size={24} />
|
<Icon name="bell-02" size={24} />
|
||||||
<HasNotificationsMarker />
|
<HasNotificationsMarker />
|
||||||
</Link>
|
</Link>
|
||||||
<ProfileLink pubkey={publicKey} user={profile}>
|
|
||||||
<Avatar pubkey={publicKey} user={profile} icons={readOnlyIcon} />
|
|
||||||
</ProfileLink>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HasNotificationsMarker() {
|
export default NotificationsHeader;
|
||||||
const readNotifications = useLogin(s => s.readNotifications);
|
|
||||||
const notifications = useSyncExternalStore(
|
|
||||||
c => Notifications.hook(c, "*"),
|
|
||||||
() => Notifications.snapshot(),
|
|
||||||
);
|
|
||||||
const latestNotification = useMemo(
|
|
||||||
() => notifications.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
|
|
||||||
[notifications],
|
|
||||||
);
|
|
||||||
const hasNotifications = useMemo(
|
|
||||||
() => latestNotification * 1000 > readNotifications,
|
|
||||||
[notifications, readNotifications],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasNotifications) {
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<span className="has-unread absolute top-0 right-0 rounded-full"></span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AccountHeader;
|
|
@ -11,13 +11,13 @@ import { useLoginRelays } from "@/Hooks/useLoginRelays";
|
|||||||
import { LoginUnlock } from "@/Element/PinPrompt";
|
import { LoginUnlock } from "@/Element/PinPrompt";
|
||||||
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
|
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
|
||||||
import { LoginStore } from "@/Login";
|
import { LoginStore } from "@/Login";
|
||||||
import { NoteCreatorButton } from "@/Element/Event/Create/NoteCreatorButton";
|
|
||||||
import NavSidebar from "./NavSidebar";
|
import NavSidebar from "./NavSidebar";
|
||||||
import AccountHeader from "./AccountHeader";
|
import NotificationsHeader from "./NotificationsHeader";
|
||||||
import RightColumn from "./RightColumn";
|
import RightColumn from "./RightColumn";
|
||||||
import { LogoHeader } from "./LogoHeader";
|
import { LogoHeader } from "./LogoHeader";
|
||||||
import useLoginFeed from "@/Feed/LoginFeed";
|
import useLoginFeed from "@/Feed/LoginFeed";
|
||||||
import ErrorBoundary from "@/Element/ErrorBoundary";
|
import ErrorBoundary from "@/Element/ErrorBoundary";
|
||||||
|
import Footer from "@/Pages/Layout/Footer";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -69,13 +69,11 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
<RightColumn />
|
<RightColumn />
|
||||||
</div>
|
</div>
|
||||||
<div className="md:hidden">
|
|
||||||
<NoteCreatorButton className="note-create-button" />
|
|
||||||
</div>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
<LoginUnlock />
|
<LoginUnlock />
|
||||||
{isStalker && <StalkerModal id={id} />}
|
{isStalker && <StalkerModal id={id} />}
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -83,8 +81,8 @@ export default function Index() {
|
|||||||
function Header() {
|
function Header() {
|
||||||
return (
|
return (
|
||||||
<header className="flex justify-between items-center self-stretch px-4 gap-6 sticky top-0 md:hidden z-10 bg-bg-color py-1">
|
<header className="flex justify-between items-center self-stretch px-4 gap-6 sticky top-0 md:hidden z-10 bg-bg-color py-1">
|
||||||
<LogoHeader />
|
<LogoHeader showText={true} />
|
||||||
<AccountHeader />
|
<NotificationsHeader />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -305,8 +305,10 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
|||||||
{showProfileQr && (
|
{showProfileQr && (
|
||||||
<Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
<Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||||
<ProfileImage pubkey={id} />
|
<ProfileImage pubkey={id} />
|
||||||
<QrCode data={link} className="m10 align-center" />
|
<div className="flex flex-col items-center">
|
||||||
<Copy text={link} className="align-center" />
|
<QrCode data={link} className="m10" />
|
||||||
|
<Copy text={link} className="py-3" />
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
|
@ -220,7 +220,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
|
|||||||
<Avatar pubkey={id} user={user} image={picture} />
|
<Avatar pubkey={id} user={user} image={picture} />
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
type="button"
|
type="button"
|
||||||
className="circle flex align-center justify-between"
|
className="circle flex items-center justify-center z-10"
|
||||||
onClick={() => setNewAvatar()}
|
onClick={() => setNewAvatar()}
|
||||||
disabled={readonly}>
|
disabled={readonly}>
|
||||||
<Icon name="upload-01" />
|
<Icon name="upload-01" />
|
||||||
|
@ -767,12 +767,6 @@ button.tall {
|
|||||||
min-width: 98px;
|
min-width: 98px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-center {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
.fade-in {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
animation-name: fadeInOpacity;
|
animation-name: fadeInOpacity;
|
||||||
|
@ -294,9 +294,6 @@
|
|||||||
"9WRlF4": {
|
"9WRlF4": {
|
||||||
"defaultMessage": "Send"
|
"defaultMessage": "Send"
|
||||||
},
|
},
|
||||||
"9X9Q2t": {
|
|
||||||
"defaultMessage": "Not followed by anyone you follow"
|
|
||||||
},
|
|
||||||
"9kSari": {
|
"9kSari": {
|
||||||
"defaultMessage": "Retry publishing"
|
"defaultMessage": "Retry publishing"
|
||||||
},
|
},
|
||||||
@ -549,6 +546,9 @@
|
|||||||
"Ig9/a1": {
|
"Ig9/a1": {
|
||||||
"defaultMessage": "Sent {n} sats to {name}"
|
"defaultMessage": "Sent {n} sats to {name}"
|
||||||
},
|
},
|
||||||
|
"IgsWFG": {
|
||||||
|
"defaultMessage": "Not followed by anyone you follow"
|
||||||
|
},
|
||||||
"IoQq+a": {
|
"IoQq+a": {
|
||||||
"defaultMessage": "Click here to load anyway"
|
"defaultMessage": "Click here to load anyway"
|
||||||
},
|
},
|
||||||
|
@ -97,7 +97,6 @@
|
|||||||
"9HU8vw": "Reply",
|
"9HU8vw": "Reply",
|
||||||
"9SvQep": "Follows {n}",
|
"9SvQep": "Follows {n}",
|
||||||
"9WRlF4": "Send",
|
"9WRlF4": "Send",
|
||||||
"9X9Q2t": "Not followed by anyone you follow",
|
|
||||||
"9kSari": "Retry publishing",
|
"9kSari": "Retry publishing",
|
||||||
"9pMqYs": "Nostr Address",
|
"9pMqYs": "Nostr Address",
|
||||||
"9wO4wJ": "Lightning Invoice",
|
"9wO4wJ": "Lightning Invoice",
|
||||||
@ -181,6 +180,7 @@
|
|||||||
"IVbtTS": "Zap all {n} sats",
|
"IVbtTS": "Zap all {n} sats",
|
||||||
"IWz1ta": "Auto Translate",
|
"IWz1ta": "Auto Translate",
|
||||||
"Ig9/a1": "Sent {n} sats to {name}",
|
"Ig9/a1": "Sent {n} sats to {name}",
|
||||||
|
"IgsWFG": "Not followed by anyone you follow",
|
||||||
"IoQq+a": "Click here to load anyway",
|
"IoQq+a": "Click here to load anyway",
|
||||||
"Ix8l+B": "Trending Notes",
|
"Ix8l+B": "Trending Notes",
|
||||||
"J+dIsA": "Subscriptions",
|
"J+dIsA": "Subscriptions",
|
||||||
|
Reference in New Issue
Block a user