mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-30 05:34:55 +02:00
✨(frontend) add search input in the header
Allow the user to search in their email through a search input
This commit is contained in:
committed by
Jean-Baptiste PENRATH
parent
b887e926c7
commit
37d689bcb0
@@ -275,8 +275,8 @@ class ThreadViewSet(
|
||||
es_filters[f"is_{field_name}"] = False
|
||||
|
||||
# Get page parameters
|
||||
page = self.paginator.get_page_number(request, self)
|
||||
page_size = self.paginator.get_page_size(request)
|
||||
page = int(self.paginator.get_page_number(request, self))
|
||||
page_size = int(self.paginator.get_page_size(request))
|
||||
|
||||
# Get search results from Elasticsearch
|
||||
results = search_threads(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.search {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
outline: 5px auto Highlight;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
|
||||
> .search__input {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.search__label {
|
||||
padding-left: var(--c--theme--spacings--sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.search__input {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: var(--c--theme--spacings--sm);
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.search{
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export const SearchInput = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [value, setValue] = useState<string>(searchParams.get('search') || '');
|
||||
const { t } = useTranslation();
|
||||
const isUserTyping = useRef(false);
|
||||
|
||||
/**
|
||||
* Each time the user types, we update the URL with the new search query.
|
||||
*/
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = event.target.value;
|
||||
isUserTyping.current = true;
|
||||
setValue(query);
|
||||
|
||||
const url = new URL(router.asPath, 'http://localhost');
|
||||
if (query) {
|
||||
url.searchParams.set('search', query);
|
||||
} else {
|
||||
url.searchParams.delete('search');
|
||||
}
|
||||
|
||||
router.replace(url.pathname + url.search, undefined, { shallow: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Each time the URL changes, we update the search query
|
||||
* except when the user is typing to prevent the cursor from jumping
|
||||
* to the end of the input.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Only update the value from searchParams if the user is not currently typing
|
||||
if (!isUserTyping.current) {
|
||||
setValue(searchParams.get('search') || '');
|
||||
}
|
||||
isUserTyping.current = false;
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="search">
|
||||
<label className="search__label" htmlFor="search">
|
||||
<span className="material-icons">search</span>
|
||||
<span className="c__offscreen">{t("search.placeholder")}</span>
|
||||
</label>
|
||||
<input
|
||||
className="search__input"
|
||||
id="search"
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={handleSearch}
|
||||
placeholder={t("search.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,9 @@
|
||||
},
|
||||
"message_editor": {
|
||||
"start_typing": "Start typing..."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search..."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -170,6 +173,9 @@
|
||||
},
|
||||
"message_editor": {
|
||||
"start_typing": "Commencer à écrire..."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
nav {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
height: var(--header-height);
|
||||
background-color: #fff;
|
||||
border: 1px grey solid;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.c__header {
|
||||
--header-height: 52px;
|
||||
display: flex;
|
||||
background-color: var(--c--theme--colors--greyscale-000);
|
||||
border-bottom: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
padding: 0 1.125rem;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
&__toggle-menu {
|
||||
display: none;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--c--theme--colors--primary-800);
|
||||
}
|
||||
}
|
||||
|
||||
&__left,
|
||||
&__center,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__center {
|
||||
background: red;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__language-picker {
|
||||
@media screen and (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.c__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const HeaderRight = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="c__header-right">
|
||||
<>
|
||||
{user && (
|
||||
<DropdownMenu
|
||||
options={[
|
||||
@@ -41,12 +41,12 @@ export const HeaderRight = () => {
|
||||
}
|
||||
iconPosition="right"
|
||||
>
|
||||
{t("my_account")}
|
||||
<span className="text-nowrap">{t("my_account")}</span>
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -111,6 +111,7 @@ const FolderItem = ({ folder }: FolderItemProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/mailbox/${selectedMailbox?.id}?${queryParams}`}
|
||||
shallow={false}
|
||||
className={clsx("mailbox__item", {
|
||||
"mailbox__item--active": isActive
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: calc(100vh - 52px);
|
||||
height: calc(100vh - var(--header-height));
|
||||
|
||||
& > div {
|
||||
font-size: var(--c--theme--font--sizes--h4);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.c__header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-areas: "left center right";
|
||||
background-color: var(--c--theme--colors--greyscale-000);
|
||||
border-bottom: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
padding: 0 1.125rem;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"toggle left right"
|
||||
"center center center";
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
&__toggle-menu {
|
||||
display: none;
|
||||
grid-area: toggle;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--c--theme--colors--primary-800);
|
||||
}
|
||||
}
|
||||
|
||||
&__left,
|
||||
&__center,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__left {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
&__center {
|
||||
grid-area: center;
|
||||
}
|
||||
|
||||
&__right {
|
||||
grid-area: right;
|
||||
justify-content: flex-end;
|
||||
&__language-picker {
|
||||
@media screen and (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { SearchInput } from "@/features/forms/components/search-input";
|
||||
import { DropdownMenuOption, LanguagePicker } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
import { Button, useCunningham } from "@openfun/cunningham-react";
|
||||
|
||||
export type HeaderProps = {
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
languages?: DropdownMenuOption[];
|
||||
onTogglePanel?: () => void;
|
||||
isPanelOpen?: boolean;
|
||||
};
|
||||
|
||||
export const Header = ({
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
languages,
|
||||
onTogglePanel,
|
||||
isPanelOpen,
|
||||
}: HeaderProps) => {
|
||||
const { t } = useCunningham();
|
||||
return (
|
||||
<div className="c__header">
|
||||
<div className="c__header__toggle-menu">
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={onTogglePanel}
|
||||
aria-label={isPanelOpen ? t("Close the menu") : t("Open the menu")}
|
||||
color="tertiary-text"
|
||||
icon={
|
||||
<span className="material-icons clr-primary-800">
|
||||
{isPanelOpen ? "close" : "menu"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="c__header__left">{leftIcon}</div>
|
||||
<div className="c__header__center">
|
||||
<SearchInput />
|
||||
</div>
|
||||
<div className="c__header__right">
|
||||
{languages && (
|
||||
<div className="c__header__right__language-picker">
|
||||
<LanguagePicker languages={languages} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightIcon}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// TODO: This hook is from Cunningham, it should be exporter from it.
|
||||
|
||||
/**
|
||||
* This hook is used to create a state that can be controlled by the parent. If not the state is handled internally.
|
||||
*
|
||||
* @param defaultValue if not controlled by the parent, this is the default value
|
||||
* @param propsValue if controlled by the parent, this is the value
|
||||
* @param propsCallback if controlled by the parent, this is the callback to call when the value changes
|
||||
*/
|
||||
export const useControllableState = <T>(
|
||||
defaultValue: T,
|
||||
propsValue?: T,
|
||||
propsCallback?: (value: T) => void
|
||||
): [T, (value: T) => void] => {
|
||||
const [state, setState] = React.useState(
|
||||
typeof propsValue === "undefined" ? defaultValue : propsValue
|
||||
);
|
||||
|
||||
// Bottom-Up.
|
||||
const onChange = (value: T) => {
|
||||
if (propsCallback) {
|
||||
propsCallback(value);
|
||||
} else {
|
||||
setState(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Top-Down.
|
||||
useEffect(() => {
|
||||
if (!propsCallback) {
|
||||
return;
|
||||
}
|
||||
if (typeof propsValue !== "undefined" && propsValue !== state) {
|
||||
setState(propsValue);
|
||||
}
|
||||
|
||||
}, [propsValue]);
|
||||
|
||||
return [state, onChange];
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import config from "@/../cunningham";
|
||||
|
||||
export const useResponsive = () => {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const mobile = parseInt(
|
||||
config.themes.default.theme.breakpoints.mobile.replace("px", "")
|
||||
);
|
||||
const tablet = parseInt(
|
||||
config.themes.default.theme.breakpoints.tablet.replace("px", "")
|
||||
);
|
||||
|
||||
const handleWindowSizeChange = () => {
|
||||
setWidth(window.innerWidth);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleWindowSizeChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleWindowSizeChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMobile = width <= mobile;
|
||||
const isTablet = width <= tablet;
|
||||
const isDesktop = width > tablet;
|
||||
|
||||
return { isMobile, isTablet, isDesktop };
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MainLayout as KitMainLayout } from "@gouvfr-lasuite/ui-kit";
|
||||
import { AppLayout } from "./layout";
|
||||
import { Header, HeaderRight } from "../header";
|
||||
import { MailboxPanel } from "@/features/layouts/components/mailbox-panel";
|
||||
import { PropsWithChildren } from "react";
|
||||
@@ -34,13 +34,15 @@ const MainLayoutContent = ({ children }: PropsWithChildren) => {
|
||||
<NoMailbox />
|
||||
</>
|
||||
) : (
|
||||
<KitMainLayout
|
||||
enableResize
|
||||
leftPanelContent={<MailboxPanel />}
|
||||
icon={<img src="/images/app-logo.svg" alt="logo" height={32} />}
|
||||
rightHeaderContent={<HeaderRight />}
|
||||
>
|
||||
<>
|
||||
<AppLayout
|
||||
enableResize
|
||||
leftPanelContent={<MailboxPanel />}
|
||||
icon={<img src="/images/app-logo.svg" alt="logo" height={32} />}
|
||||
rightHeaderContent={<HeaderRight />}
|
||||
>
|
||||
{children}
|
||||
</KitMainLayout>
|
||||
</AppLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.c__main-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.c__main-layout__header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.c__main-layout__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
margin-top: var(--header-height);
|
||||
|
||||
&__left-panel {
|
||||
height: calc(100dvh - var(--header-height));
|
||||
width: 300px;
|
||||
border-right: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
@media screen and (max-width: 1024px) {
|
||||
width: 100dvw;
|
||||
}
|
||||
}
|
||||
&__center {
|
||||
display: flex;
|
||||
|
||||
&__children {
|
||||
height: calc(100dvh - var(--header-height));
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import { Header } from "../header";
|
||||
import { useResponsive } from "../hooks/useResponsive";
|
||||
import {
|
||||
ImperativePanelHandle,
|
||||
Panel,
|
||||
PanelGroup,
|
||||
PanelResizeHandle,
|
||||
} from "react-resizable-panels";
|
||||
import { DropdownMenuOption,LeftPanel } from "@gouvfr-lasuite/ui-kit";
|
||||
import { RightPanel } from "../right-panel";
|
||||
import { useControllableState } from "../hooks/useControllableState";
|
||||
export type MainLayoutProps = {
|
||||
icon?: React.ReactNode;
|
||||
leftPanelContent?: React.ReactNode;
|
||||
rightPanelContent?: React.ReactNode;
|
||||
rightHeaderContent?: React.ReactNode;
|
||||
languages?: DropdownMenuOption[];
|
||||
onToggleRightPanel?: () => void;
|
||||
enableResize?: boolean;
|
||||
rightPanelIsOpen?: boolean;
|
||||
hideLeftPanelOnDesktop?: boolean;
|
||||
isLeftPanelOpen?: boolean;
|
||||
setIsLeftPanelOpen?: (isLeftPanelOpen: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This component is a copy of the MainLayout component from our ui-kit.
|
||||
*
|
||||
* @TODO: Remove this and update the ui-kit to be able to render panels without header or
|
||||
* add props to fully override the header.
|
||||
*/
|
||||
export const AppLayout = ({
|
||||
icon,
|
||||
children,
|
||||
hideLeftPanelOnDesktop = false,
|
||||
leftPanelContent,
|
||||
rightPanelContent,
|
||||
rightHeaderContent,
|
||||
|
||||
languages,
|
||||
enableResize = false,
|
||||
rightPanelIsOpen = false,
|
||||
|
||||
...props
|
||||
}: PropsWithChildren<MainLayoutProps>) => {
|
||||
const [isLeftPanelOpen, setIsLeftPanelOpen] = useControllableState(
|
||||
false,
|
||||
props.isLeftPanelOpen,
|
||||
props.setIsLeftPanelOpen
|
||||
);
|
||||
|
||||
const { isDesktop } = useResponsive();
|
||||
const ref = useRef<ImperativePanelHandle>(null);
|
||||
|
||||
const [minPanelSize, setMinPanelSize] = useState(
|
||||
calculateDefaultSize(300, isDesktop)
|
||||
);
|
||||
const [maxPanelSize, setMaxPanelSize] = useState(
|
||||
calculateDefaultSize(450, isDesktop)
|
||||
);
|
||||
|
||||
const onTogglePanel = () => {
|
||||
setIsLeftPanelOpen(!isLeftPanelOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updatePanelSize = () => {
|
||||
const min = Math.round(calculateDefaultSize(300, isDesktop));
|
||||
const max = Math.round(
|
||||
Math.min(calculateDefaultSize(450, isDesktop), 40)
|
||||
);
|
||||
setMinPanelSize(isDesktop ? min : 0);
|
||||
if (enableResize) {
|
||||
setMaxPanelSize(max);
|
||||
} else {
|
||||
setMaxPanelSize(min);
|
||||
}
|
||||
};
|
||||
|
||||
updatePanelSize();
|
||||
window.addEventListener("resize", () => {
|
||||
updatePanelSize();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePanelSize);
|
||||
};
|
||||
}, [isDesktop, enableResize]);
|
||||
|
||||
// We need to have two different states for the left panel, we want to always keep the
|
||||
// left panel mounted on mobile in order to show the animation when it opens or closes, instead
|
||||
// of abruptly disappearing when closing the panel.
|
||||
// On desktop, we want to hide the left panel when the prop is set to true, so we need to
|
||||
// completely unmount it as it will never be visible.
|
||||
const mountLeftPanel = isDesktop ? !hideLeftPanelOnDesktop : true;
|
||||
const showLeftPanel = isDesktop ? !hideLeftPanelOnDesktop : isLeftPanelOpen;
|
||||
|
||||
return (
|
||||
<div className="c__main-layout">
|
||||
<div className="c__main-layout__header">
|
||||
<Header
|
||||
onTogglePanel={onTogglePanel}
|
||||
isPanelOpen={isLeftPanelOpen}
|
||||
rightIcon={rightHeaderContent}
|
||||
leftIcon={icon}
|
||||
languages={languages}
|
||||
/>
|
||||
</div>
|
||||
<div className="c__main-layout__content">
|
||||
<PanelGroup autoSaveId={"persistance"} direction="horizontal">
|
||||
{mountLeftPanel && (
|
||||
<>
|
||||
<Panel
|
||||
ref={ref}
|
||||
order={0}
|
||||
defaultSize={minPanelSize}
|
||||
minSize={minPanelSize}
|
||||
maxSize={maxPanelSize}
|
||||
>
|
||||
<LeftPanel isOpen={showLeftPanel}>{leftPanelContent}</LeftPanel>
|
||||
</Panel>
|
||||
{isDesktop && (
|
||||
<PanelResizeHandle
|
||||
className="bg-greyscale-200"
|
||||
style={{
|
||||
width: "1px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Panel order={1}>
|
||||
<div className="c__main-layout__content__center">
|
||||
<div className="c__main-layout__content__center__children">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<RightPanel isOpen={rightPanelIsOpen}>
|
||||
{rightPanelContent}
|
||||
</RightPanel>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateDefaultSize = (targetWidth: number, isDesktop: boolean) => {
|
||||
if (!isDesktop) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
return (targetWidth / windowWidth) * 100;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
.c__right-panel {
|
||||
height: calc(100dvh - var(--header-height));
|
||||
width: 0;
|
||||
border-left: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
transition: width 0.15s ease-in-out;
|
||||
overflow-y: auto;
|
||||
|
||||
&.open {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.c__right-panel__mobile {
|
||||
z-index: 998;
|
||||
width: 100dvw;
|
||||
height: calc(100dvh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
background-color: var(--c--theme--colors--greyscale-000);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
transform: translateX(100dvw);
|
||||
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useResponsive } from "../hooks/useResponsive";
|
||||
import clsx from "clsx";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export type RightPanelProps = {
|
||||
isOpen?: boolean;
|
||||
};
|
||||
export const RightPanel = ({
|
||||
children,
|
||||
isOpen,
|
||||
}: PropsWithChildren<RightPanelProps>) => {
|
||||
const { isDesktop } = useResponsive();
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<div className={clsx("c__right-panel__mobile", { open: isOpen })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("c__right-panel", { open: isOpen })}>{children}</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,21 @@
|
||||
.thread-view {
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.thread-view {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-view--loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/router";
|
||||
import usePrevious from "@/hooks/use-previous";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DEFAULT_FOLDERS } from "../layouts/components/mailbox-panel/components/mailbox-list";
|
||||
import { useDebounceCallback } from "@/hooks/use-debounce-callback";
|
||||
|
||||
type QueryState = {
|
||||
status: QueryStatus,
|
||||
@@ -94,6 +95,10 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const previousSearchParams = usePrevious(searchParams);
|
||||
const hasSearchParamsChanged = useMemo(() => {
|
||||
return previousSearchParams?.toString() !== searchParams.toString();
|
||||
}, [previousSearchParams, searchParams]);
|
||||
const [selectedMailbox, setSelectedMailbox] = useState<Mailbox | null>(null);
|
||||
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
|
||||
const mailboxQuery = useMailboxesList({
|
||||
@@ -103,11 +108,18 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
|
||||
});
|
||||
|
||||
const previousUnreadMessagesCount = usePrevious(selectedMailbox?.count_unread_messages || 0);
|
||||
const threadQueryKey = useMemo(() => {
|
||||
const queryKey = ['threads', selectedMailbox?.id];
|
||||
if (searchParams.get('search')) {
|
||||
return [...queryKey, 'search'];
|
||||
}
|
||||
return [...queryKey, searchParams.toString()];
|
||||
}, [selectedMailbox?.id, searchParams]);
|
||||
const threadsQuery = useThreadsListInfinite(undefined, {
|
||||
query: {
|
||||
enabled: !!selectedMailbox,
|
||||
initialPageParam: 1,
|
||||
queryKey: ['threads', selectedMailbox?.id, searchParams.toString()],
|
||||
queryKey: threadQueryKey,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
return pages.length + 1;
|
||||
},
|
||||
@@ -157,6 +169,11 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['messages', selectedThread.id] });
|
||||
}
|
||||
}
|
||||
const resetSearchQueryDebounced = useDebounceCallback(() => {
|
||||
queryClient.resetQueries(
|
||||
{ predicate: ({ queryKey}) => queryKey.includes('search') },
|
||||
);
|
||||
}, 500);
|
||||
|
||||
const invalidateThreadsStats = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['threads', 'stats', selectedMailbox?.id] });
|
||||
@@ -167,7 +184,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
|
||||
*/
|
||||
const unselectThread = () => {
|
||||
setSelectedThread(null);
|
||||
if (router.query.threadId) {
|
||||
if (selectedMailbox && router.query.threadId) {
|
||||
router.push(`/mailbox/${selectedMailbox!.id}?${searchParams}`);
|
||||
}
|
||||
}
|
||||
@@ -270,6 +287,13 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
}, [selectedMailbox?.count_unread_messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('search') !== previousSearchParams?.get('search')) {
|
||||
resetSearchQueryDebounced();
|
||||
}
|
||||
unselectThread();
|
||||
}, [hasSearchParamsChanged])
|
||||
|
||||
return <MailboxContext.Provider value={context}>{children}</MailboxContext.Provider>
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
$tablet: map.get($themes, "default", "theme", "breakpoints", "tablet");
|
||||
|
||||
.app__home {
|
||||
--header-height: 52px;
|
||||
|
||||
.c__header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
@@ -31,8 +29,6 @@ $tablet: map.get($themes, "default", "theme", "breakpoints", "tablet");
|
||||
}
|
||||
|
||||
@media (max-width: $tablet) {
|
||||
--header-height: 52px;
|
||||
|
||||
.c__header {
|
||||
.drive__header__left {
|
||||
img:first-child {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
:root {
|
||||
font-size: 14px;
|
||||
--header-height: 52px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
:root {
|
||||
--header-height: 104px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -108,4 +115,8 @@ body {
|
||||
|
||||
.m-base {
|
||||
margin: var(--c--theme--spacings--base);
|
||||
}
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
@use "./cunningham-tokens.css";
|
||||
@use "./globals.scss";
|
||||
|
||||
// UI Kit components fork
|
||||
@use "./../features/layouts/components/main/right-panel";
|
||||
@use "./../features/layouts/components/main/header";
|
||||
@use "./../features/layouts/components/main/layout";
|
||||
// Application Components
|
||||
@use "./../features/layouts/components/global/global-layout.scss";
|
||||
@use "./../features/layouts/components/main";
|
||||
@use "./../features/layouts/components/header";
|
||||
// @use "./../features/layouts/components/header";
|
||||
@use "./../features/ui/components/users";
|
||||
@use "./../features/ui/components/breadcrumbs";
|
||||
@use "./../features/ui/components/toaster";
|
||||
@@ -26,7 +30,7 @@
|
||||
@use "./../features/layouts/components/thread-view/components/message-reply-form";
|
||||
@use "./../features/forms/components/message-form";
|
||||
@use "./../features/forms/components/message-editor";
|
||||
|
||||
@use "./../features/forms/components/search-input";
|
||||
// Application views
|
||||
@use "./../pages";
|
||||
@use "./../pages/mailbox";
|
||||
|
||||
Reference in New Issue
Block a user