1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-24 20:36:40 +07:00

added notes sidebar

This commit is contained in:
Fedor Katurov 2022-08-03 16:16:34 +07:00
parent fe3db608d6
commit cedf0adcfa
14 changed files with 376 additions and 153 deletions

View file

@ -1,8 +1,20 @@
import { ApiGetNotesRequest, ApiGetNotesResponse } from '~/api/notes/types';
import { URLS } from '~/constants/urls';
import { api, cleanResult } from '~/utils/api';
import {
ApiGetNotesRequest,
ApiGetNotesResponse,
ApiPostNoteRequest,
ApiPostNoteResponse,
} from "~/api/notes/types";
import { URLS } from "~/constants/urls";
import { api, cleanResult } from "~/utils/api";
export const apiGetNotes = ({ limit, offset, search }: ApiGetNotesRequest) =>
api
.get<ApiGetNotesResponse>(URLS.NOTES, { params: { limit, offset, search } })
.then(cleanResult);
export const apiPostNote = ({ text }: ApiPostNoteRequest) =>
api
.post<ApiPostNoteResponse>(URLS.NOTES, {
text,
})
.then(cleanResult);

View file

@ -1,4 +1,4 @@
import { Note } from '~/types/notes';
import { Note } from "~/types/notes";
export interface ApiGetNotesRequest {
limit: number;
@ -10,3 +10,9 @@ export interface ApiGetNotesResponse {
list: Note[];
totalCount: number;
}
export interface ApiPostNoteRequest {
text: string;
}
export interface ApiPostNoteResponse extends Note {}

View file

@ -1,12 +1,12 @@
import React, { VFC } from 'react';
import React, { VFC } from "react";
import { Card } from '~/components/containers/Card';
import { Markdown } from '~/components/containers/Markdown';
import { Padder } from '~/components/containers/Padder';
import { NoteMenu } from '~/components/notes/NoteMenu';
import { formatText, getPrettyDate } from '~/utils/dom';
import { Card } from "~/components/containers/Card";
import { Markdown } from "~/components/containers/Markdown";
import { Padder } from "~/components/containers/Padder";
import { NoteMenu } from "~/components/notes/NoteMenu";
import { formatText, getPrettyDate } from "~/utils/dom";
import styles from './styles.module.scss';
import styles from "./styles.module.scss";
interface NoteCardProps {
content: string;
@ -17,7 +17,10 @@ const NoteCard: VFC<NoteCardProps> = ({ content, createdAt }) => (
<Card className={styles.note}>
<Padder>
<NoteMenu onEdit={console.log} onDelete={console.log} />
<Markdown className={styles.wrap} dangerouslySetInnerHTML={{ __html: formatText(content) }} />
<Markdown
className={styles.wrap}
dangerouslySetInnerHTML={{ __html: formatText(content) }}
/>
</Padder>
<Padder className={styles.footer}>{getPrettyDate(createdAt)}</Padder>

View file

@ -6,10 +6,6 @@
word-break: break-word;
padding: 0;
position: relative;
& > * {
@include row_shadow;
}
}
.footer {

View file

@ -0,0 +1,97 @@
import React, { FC, useCallback, useState } from "react";
import { FormikConfig, useFormik } from "formik";
import { object, string, Asserts } from "yup";
import { Card } from "~/components/containers/Card";
import { Filler } from "~/components/containers/Filler";
import { Group } from "~/components/containers/Group";
import { Button } from "~/components/input/Button";
import { Textarea } from "~/components/input/Textarea";
import { useRandomPhrase } from "~/constants/phrases";
import { Note } from "~/types/notes";
import { getErrorMessage } from "~/utils/errors/getErrorMessage";
import { showErrorToast } from "~/utils/errors/showToast";
import styles from "./styles.module.scss";
interface NoteCreationFormProps {
onSubmit: (text: string, callback: (note: Note) => void) => void;
onCancel: () => void;
}
const validationSchema = object({
text: string().required("Напишите что-нибудь"),
});
type Values = Asserts<typeof validationSchema>;
const NoteCreationForm: FC<NoteCreationFormProps> = ({
onSubmit,
onCancel,
}) => {
const placeholder = useRandomPhrase("SIMPLE");
const submit = useCallback<FormikConfig<Values>["onSubmit"]>(
async ({ text }, { resetForm, setSubmitting, setErrors }) => {
try {
await onSubmit(text, () => resetForm());
} catch (error) {
const message = getErrorMessage(error);
if (message) {
setErrors({ text: message });
return;
}
showErrorToast(error);
} finally {
setSubmitting(false);
}
},
[onSubmit],
);
const {
values,
errors,
handleChange,
handleSubmit,
touched,
handleBlur,
} = useFormik<Values>({
initialValues: { text: "" },
validationSchema,
onSubmit: submit,
});
return (
<form onSubmit={handleSubmit}>
<Card className={styles.card}>
<div className={styles.row}>
<Textarea
handler={handleChange("text")}
value={values.text}
error={touched.text ? errors.text : undefined}
onBlur={handleBlur("text")}
placeholder={placeholder}
autoFocus
/>
</div>
<Group horizontal className={styles.row}>
<Filler />
<Button size="mini" type="button" color="link" onClick={onCancel}>
Отмена
</Button>
<Button size="mini" type="submit" color="gray">
ОК
</Button>
</Group>
</Card>
</form>
);
};
export { NoteCreationForm };

View file

@ -0,0 +1,11 @@
@import "src/styles/variables";
.card {
padding: 0;
}
.row {
@include row_shadow;
padding: $gap / 2;
}

View file

@ -1,10 +1,8 @@
import React, { VFC } from 'react';
import { VFC } from "react";
import { useStackContext } from '~/components/sidebar/SidebarStack';
import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard';
import { SettingsNotes } from '~/containers/settings/SettingsNotes';
import styles from './styles.module.scss';
import { useStackContext } from "~/components/sidebar/SidebarStack";
import { SidebarStackCard } from "~/components/sidebar/SidebarStackCard";
import { SettingsNotes } from "~/containers/settings/SettingsNotes";
interface ProfileSidebarNotesProps {}
@ -12,10 +10,13 @@ const ProfileSidebarNotes: VFC<ProfileSidebarNotesProps> = () => {
const { closeAllTabs } = useStackContext();
return (
<SidebarStackCard width={800} headerFeature="back" title="Заметки" onBackPress={closeAllTabs}>
<div className={styles.scroller}>
<SidebarStackCard
width={480}
headerFeature="back"
title="Заметки"
onBackPress={closeAllTabs}
>
<SettingsNotes />
</div>
</SidebarStackCard>
);
};

View file

@ -1,22 +1,22 @@
import React, { useCallback, VFC } from 'react';
import React, { useCallback, VFC } from "react";
import classNames from 'classnames';
import classNames from "classnames";
import { Filler } from '~/components/containers/Filler';
import { Group } from '~/components/containers/Group';
import { Button } from '~/components/input/Button';
import { Icon } from '~/components/input/Icon';
import { MenuButton, MenuItemWithIcon } from '~/components/menu';
import { VerticalMenu } from '~/components/menu/VerticalMenu';
import { useStackContext } from '~/components/sidebar/SidebarStack';
import { ProfileSidebarHead } from '~/containers/profile/ProfileSidebarHead';
import { ProfileStats } from '~/containers/profile/ProfileStats';
import { useAuth } from '~/hooks/auth/useAuth';
import markdown from '~/styles/common/markdown.module.scss';
import { Filler } from "~/components/containers/Filler";
import { Group } from "~/components/containers/Group";
import { Button } from "~/components/input/Button";
import { Icon } from "~/components/input/Icon";
import { MenuButton, MenuItemWithIcon } from "~/components/menu";
import { VerticalMenu } from "~/components/menu/VerticalMenu";
import { useStackContext } from "~/components/sidebar/SidebarStack";
import { ProfileSidebarHead } from "~/containers/profile/ProfileSidebarHead";
import { ProfileStats } from "~/containers/profile/ProfileStats";
import { useAuth } from "~/hooks/auth/useAuth";
import markdown from "~/styles/common/markdown.module.scss";
import { ProfileSidebarLogoutButton } from '../ProfileSidebarLogoutButton';
import { ProfileSidebarLogoutButton } from "../ProfileSidebarLogoutButton";
import styles from './styles.module.scss';
import styles from "./styles.module.scss";
interface ProfileSidebarMenuProps {
onClose: () => void;
@ -40,7 +40,13 @@ const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
<Filler className={classNames(markdown.wrapper, styles.text)}>
<Group>
<VerticalMenu className={styles.menu}>
<VerticalMenu.Item onClick={() => setActiveTab(0)}>Настройки</VerticalMenu.Item>
<VerticalMenu.Item onClick={() => setActiveTab(0)}>
Настройки
</VerticalMenu.Item>
<VerticalMenu.Item onClick={() => setActiveTab(1)}>
Заметки
</VerticalMenu.Item>
</VerticalMenu>
<div className={styles.stats}>
@ -51,7 +57,7 @@ const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
<Group className={styles.buttons} horizontal>
<Filler />
<ProfileSidebarLogoutButton onLogout={onLogout}/>
<ProfileSidebarLogoutButton onLogout={onLogout} />
</Group>
</div>
);

View file

@ -1,56 +1,69 @@
import React, { useState, VFC } from 'react';
import { FC, useState, VFC } from "react";
import { Card } from '~/components/containers/Card';
import { Columns } from '~/components/containers/Columns';
import { Filler } from '~/components/containers/Filler';
import { Group } from '~/components/containers/Group';
import { Padder } from '~/components/containers/Padder';
import { Button } from '~/components/input/Button';
import { Icon } from '~/components/input/Icon';
import { InputText } from '~/components/input/InputText';
import { Textarea } from '~/components/input/Textarea';
import { HorizontalMenu } from '~/components/menu/HorizontalMenu';
import { NoteCard } from '~/components/notes/NoteCard';
import { useGetNotes } from '~/hooks/notes/useGetNotes';
import { Filler } from "~/components/containers/Filler";
import { Group } from "~/components/containers/Group";
import { Button } from "~/components/input/Button";
import { NoteCard } from "~/components/notes/NoteCard";
import { NoteCreationForm } from "~/components/notes/NoteCreationForm";
import { NoteProvider, useNotesContext } from "~/utils/providers/NoteProvider";
import styles from "./styles.module.scss";
interface SettingsNotesProps {}
const SettingsNotes: VFC<SettingsNotesProps> = () => {
const [text, setText] = useState('');
const { notes } = useGetNotes('');
const List = () => {
const { notes } = useNotesContext();
return (
<div>
<Padder>
<Group horizontal>
<HorizontalMenu>
<HorizontalMenu.Item active>Новые</HorizontalMenu.Item>
<HorizontalMenu.Item>Старые</HorizontalMenu.Item>
</HorizontalMenu>
<Filler />
<InputText suffix={<Icon icon="search" size={24} />} />
</Group>
</Padder>
<Columns>
<Card>
<Group>
<Textarea handler={setText} value={text} />
<Group horizontal>
<Filler />
<Button size="mini">Добавить</Button>
</Group>
</Group>
</Card>
<>
{notes.map(note => (
<NoteCard key={note.id} content={note.content} createdAt={note.created_at} />
<NoteCard
key={note.id}
content={note.content}
createdAt={note.created_at}
/>
))}
</Columns>
</>
);
};
const Form: FC<{ onCancel: () => void }> = ({ onCancel }) => {
const { submit } = useNotesContext();
return <NoteCreationForm onSubmit={submit} onCancel={onCancel} />;
};
const SettingsNotes: VFC<SettingsNotesProps> = () => {
const [formIsShown, setFormIsShown] = useState(false);
return (
<NoteProvider>
<div className={styles.grid}>
<div className={styles.head}>
{formIsShown ? (
<Form onCancel={() => setFormIsShown(false)} />
) : (
<Group className={styles.showForm} horizontal>
<Filler />
<Button
onClick={() => setFormIsShown(true)}
size="mini"
iconRight="plus"
color="secondary"
>
Добавить
</Button>
</Group>
)}
</div>
<div className={styles.list}>
<Group>
<Group>
<List />
</Group>
</Group>
</div>
</div>
</NoteProvider>
);
};

View file

@ -0,0 +1,26 @@
@import "src/styles/variables";
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 4;
height: 100%;
}
.head {
@include row_shadow;
width: 100%;
padding: $gap;
}
.list {
@include row_shadow;
overflow-y: auto;
flex: 1 1;
overflow: auto;
padding: 10px;
}

View file

@ -1,55 +0,0 @@
import { useCallback, useMemo } from 'react';
import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite';
import { apiGetNotes } from '~/api/notes';
import { ApiGetNotesRequest } from '~/api/notes/types';
import { useAuth } from '~/hooks/auth/useAuth';
import { GetLabNodesRequest, ILabNode } from '~/types/lab';
import { flatten, uniqBy } from '~/utils/ramda';
const DEFAULT_COUNT = 20;
const getKey: (isUser: boolean, search: string) => SWRInfiniteKeyLoader = (isUser, search) => (
index,
prev: ILabNode[]
) => {
if (!isUser) return null;
if (index > 0 && (!prev?.length || prev.length < 20)) return null;
const props: GetLabNodesRequest = {
limit: DEFAULT_COUNT,
offset: index * DEFAULT_COUNT,
search: search || '',
};
return JSON.stringify(props);
};
const parseKey = (key: string): ApiGetNotesRequest => {
try {
return JSON.parse(key);
} catch (error) {
return { limit: DEFAULT_COUNT, offset: 0, search: '' };
}
};
export const useGetNotes = (search: string) => {
const { isUser } = useAuth();
const { data, isValidating, size, setSize, mutate } = useSWRInfinite(
getKey(isUser, search),
async (key: string) => {
const result = await apiGetNotes(parseKey(key));
return result.list;
},
{
dedupingInterval: 300,
}
);
const notes = useMemo(() => uniqBy(n => n.id, flatten(data || [])), [data]);
const hasMore = (data?.[size - 1]?.length || 0) >= 1;
const loadMore = useCallback(() => setSize(size + 1), [setSize, size]);
return { notes, hasMore, loadMore, isLoading: !data && isValidating };
};

View file

@ -0,0 +1,81 @@
import { useCallback, useMemo, useState } from "react";
import useSWRInfinite from "swr/infinite";
import { SWRInfiniteKeyLoader } from "swr/infinite";
import { apiGetNotes, apiPostNote } from "~/api/notes";
import { ApiGetNotesRequest } from "~/api/notes/types";
import { useAuth } from "~/hooks/auth/useAuth";
import { GetLabNodesRequest, ILabNode } from "~/types/lab";
import { Note } from "~/types/notes";
import { showErrorToast } from "~/utils/errors/showToast";
import { flatten, uniqBy } from "~/utils/ramda";
const DEFAULT_COUNT = 20;
const getKey: (isUser: boolean, search: string) => SWRInfiniteKeyLoader = (
isUser,
search,
) => (index, prev: ILabNode[]) => {
if (!isUser) return null;
if (index > 0 && (!prev?.length || prev.length < 20)) return null;
const props: GetLabNodesRequest = {
limit: DEFAULT_COUNT,
offset: index * DEFAULT_COUNT,
search: search || "",
};
return JSON.stringify(props);
};
const parseKey = (key: string): ApiGetNotesRequest => {
try {
return JSON.parse(key);
} catch (error) {
return { limit: DEFAULT_COUNT, offset: 0, search: "" };
}
};
export const useNotes = (search: string) => {
const { isUser } = useAuth();
const { data, isValidating, size, setSize, mutate } = useSWRInfinite(
getKey(isUser, search),
async (key: string) => {
const result = await apiGetNotes(parseKey(key));
return result.list;
},
{
dedupingInterval: 300,
},
);
const submit = useCallback(
async (text: string, onSuccess: (note: Note) => void) => {
const result = await apiPostNote({ text });
if (data) {
mutate(data?.map((it, index) => (index === 0 ? [result, ...it] : it)));
}
onSuccess(result);
},
[],
);
const notes = useMemo(() => uniqBy(n => n.id, flatten(data || [])), [data]);
const hasMore = (data?.[size - 1]?.length || 0) >= 1;
const loadMore = useCallback(() => setSize(size + 1), [setSize, size]);
return useMemo(
() => ({
notes,
hasMore,
loadMore,
isLoading: !data && isValidating,
submit,
}),
[notes, hasMore, loadMore, data, isValidating, submit],
);
};

View file

@ -1,5 +1,7 @@
import { ERRORS } from '~/constants/errors';
import { IUser } from '~/types/auth';
import { Context } from "react";
import { ERRORS } from "~/constants/errors";
import { IUser } from "~/types/auth";
export interface ITag {
ID: number;
@ -16,10 +18,11 @@ export interface ITag {
export type IIcon = string;
export type ValueOf<T> = T[keyof T];
export type ContextValue<T> = T extends Context<infer U> ? U : never;
export type UUID = string;
export type IUploadType = 'image' | 'text' | 'audio' | 'video' | 'other';
export type IUploadType = "image" | "text" | "audio" | "video" | "other";
export interface IFile {
id: number;
@ -52,17 +55,21 @@ export interface IFile {
}
export interface IBlockText {
type: 'text';
type: "text";
text: string;
}
export interface IBlockEmbed {
type: 'video';
type: "video";
url: string;
}
export type IBlock = IBlockText | IBlockEmbed;
export type FlowDisplayVariant = 'single' | 'vertical' | 'horizontal' | 'quadro';
export type FlowDisplayVariant =
| "single"
| "vertical"
| "horizontal"
| "quadro";
export interface FlowDisplay {
display: FlowDisplayVariant;
show_description: boolean;
@ -102,7 +109,7 @@ export interface INode {
export type IFlowNode = Pick<
INode,
'id' | 'flow' | 'description' | 'title' | 'thumbnail' | 'created_at'
"id" | "flow" | "description" | "title" | "thumbnail" | "created_at"
>;
export interface IComment {
@ -116,7 +123,7 @@ export interface IComment {
deleted_at?: string;
}
export type IMessage = Omit<IComment, 'user' | 'node'> & {
export type IMessage = Omit<IComment, "user" | "node"> & {
from: IUser;
to: IUser;
};
@ -125,7 +132,7 @@ export interface ICommentGroup {
user: IUser;
comments: IComment[];
distancesInDays: number[];
ids: IComment['id'][];
ids: IComment["id"][];
hasNew: boolean;
}
@ -133,19 +140,19 @@ export type IUploadProgressHandler = (progress: ProgressEvent) => void;
export type IError = ValueOf<typeof ERRORS>;
export const NOTIFICATION_TYPES = {
message: 'message',
comment: 'comment',
node: 'node',
message: "message",
comment: "comment",
node: "node",
};
export type IMessageNotification = {
type: typeof NOTIFICATION_TYPES['message'];
type: typeof NOTIFICATION_TYPES["message"];
content: Partial<IMessage>;
created_at: string;
};
export type ICommentNotification = {
type: typeof NOTIFICATION_TYPES['comment'];
type: typeof NOTIFICATION_TYPES["comment"];
content: Partial<IComment>;
created_at: string;
};

View file

@ -0,0 +1,19 @@
import { createContext, FC, useContext } from "react";
import { useNotes } from "~/hooks/notes/useNotes";
const NoteContext = createContext<ReturnType<typeof useNotes>>({
notes: [],
hasMore: false,
loadMore: async () => Promise.resolve(undefined),
isLoading: false,
submit: () => Promise.resolve(),
});
export const NoteProvider: FC = ({ children }) => {
const notes = useNotes("");
return <NoteContext.Provider value={notes}>{children}</NoteContext.Provider>;
};
export const useNotesContext = () => useContext(NoteContext);