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

added note dropping and editing

This commit is contained in:
Fedor Katurov 2022-08-05 16:56:00 +07:00
parent cedf0adcfa
commit 1bb08f72e6
11 changed files with 224 additions and 101 deletions

View file

@ -1,20 +1,31 @@
import { import {
ApiGetNotesRequest, ApiGetNotesRequest as ApiListNotesRequest,
ApiGetNotesResponse, ApiGetNotesResponse,
ApiPostNoteRequest, ApiCreateNoteRequest,
ApiPostNoteResponse, ApiUpdateNoteResponse,
ApiUpdateNoteRequest,
} from "~/api/notes/types"; } from "~/api/notes/types";
import { URLS } from "~/constants/urls"; import { URLS } from "~/constants/urls";
import { api, cleanResult } from "~/utils/api"; import { api, cleanResult } from "~/utils/api";
export const apiGetNotes = ({ limit, offset, search }: ApiGetNotesRequest) => export const apiListNotes = ({ limit, offset, search }: ApiListNotesRequest) =>
api api
.get<ApiGetNotesResponse>(URLS.NOTES, { params: { limit, offset, search } }) .get<ApiGetNotesResponse>(URLS.NOTES, { params: { limit, offset, search } })
.then(cleanResult); .then(cleanResult);
export const apiPostNote = ({ text }: ApiPostNoteRequest) => export const apiCreateNote = ({ text }: ApiCreateNoteRequest) =>
api api
.post<ApiPostNoteResponse>(URLS.NOTES, { .post<ApiUpdateNoteResponse>(URLS.NOTES, {
text, text,
}) })
.then(cleanResult); .then(cleanResult);
export const apiDeleteNote = (id: number) =>
api.delete(URLS.NOTE(id)).then(cleanResult);
export const apiUpdateNote = ({ id, text }: ApiUpdateNoteRequest) =>
api
.put<ApiUpdateNoteResponse>(URLS.NOTE(id), {
content: text,
})
.then(cleanResult);

View file

@ -11,8 +11,13 @@ export interface ApiGetNotesResponse {
totalCount: number; totalCount: number;
} }
export interface ApiPostNoteRequest { export interface ApiCreateNoteRequest {
text: string; text: string;
} }
export interface ApiPostNoteResponse extends Note {} export interface ApiUpdateNoteResponse extends Note {}
export interface ApiUpdateNoteRequest {
id: number;
text: string;
}

View file

@ -1,4 +1,4 @@
import React, { VFC } from "react"; import React, { useCallback, useState, VFC } from "react";
import { Card } from "~/components/containers/Card"; import { Card } from "~/components/containers/Card";
import { Markdown } from "~/components/containers/Markdown"; import { Markdown } from "~/components/containers/Markdown";
@ -6,25 +6,59 @@ import { Padder } from "~/components/containers/Padder";
import { NoteMenu } from "~/components/notes/NoteMenu"; import { NoteMenu } from "~/components/notes/NoteMenu";
import { formatText, getPrettyDate } from "~/utils/dom"; import { formatText, getPrettyDate } from "~/utils/dom";
import { NoteCreationForm } from "../NoteCreationForm";
import styles from "./styles.module.scss"; import styles from "./styles.module.scss";
interface NoteCardProps { interface NoteCardProps {
content: string; content: string;
remove: () => Promise<void>;
update: (text: string, callback?: () => void) => Promise<void>;
createdAt: string; createdAt: string;
} }
const NoteCard: VFC<NoteCardProps> = ({ content, createdAt }) => ( const NoteCard: VFC<NoteCardProps> = ({
<Card className={styles.note}> content,
<Padder> createdAt,
<NoteMenu onEdit={console.log} onDelete={console.log} /> remove,
<Markdown update,
className={styles.wrap} }) => {
dangerouslySetInnerHTML={{ __html: formatText(content) }} const [editing, setEditing] = useState(false);
/>
</Padder>
<Padder className={styles.footer}>{getPrettyDate(createdAt)}</Padder> const toggleEditing = useCallback(() => setEditing(v => !v), []);
</Card> const onUpdate = useCallback(
); (text: string, callback?: () => void) =>
update(text, () => {
setEditing(false);
callback?.();
}),
[],
);
return (
<Card className={styles.note}>
{editing ? (
<NoteCreationForm
text={content}
onSubmit={onUpdate}
onCancel={toggleEditing}
/>
) : (
<>
<Padder>
<NoteMenu onEdit={toggleEditing} onDelete={remove} />
<Markdown
className={styles.wrap}
dangerouslySetInnerHTML={{ __html: formatText(content) }}
/>
</Padder>
<Padder className={styles.footer}>{getPrettyDate(createdAt)}</Padder>
</>
)}
</Card>
);
};
export { NoteCard }; export { NoteCard };

View file

@ -16,7 +16,8 @@ import { showErrorToast } from "~/utils/errors/showToast";
import styles from "./styles.module.scss"; import styles from "./styles.module.scss";
interface NoteCreationFormProps { interface NoteCreationFormProps {
onSubmit: (text: string, callback: (note: Note) => void) => void; text?: string;
onSubmit: (text: string, callback: () => void) => void;
onCancel: () => void; onCancel: () => void;
} }
@ -27,15 +28,16 @@ const validationSchema = object({
type Values = Asserts<typeof validationSchema>; type Values = Asserts<typeof validationSchema>;
const NoteCreationForm: FC<NoteCreationFormProps> = ({ const NoteCreationForm: FC<NoteCreationFormProps> = ({
text = "",
onSubmit, onSubmit,
onCancel, onCancel,
}) => { }) => {
const placeholder = useRandomPhrase("SIMPLE"); const placeholder = useRandomPhrase("SIMPLE");
const submit = useCallback<FormikConfig<Values>["onSubmit"]>( const submit = useCallback<FormikConfig<Values>["onSubmit"]>(
async ({ text }, { resetForm, setSubmitting, setErrors }) => { async (values, { resetForm, setSubmitting, setErrors }) => {
try { try {
await onSubmit(text, () => resetForm()); await onSubmit(values.text, () => resetForm());
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message) { if (message) {
@ -58,8 +60,9 @@ const NoteCreationForm: FC<NoteCreationFormProps> = ({
handleSubmit, handleSubmit,
touched, touched,
handleBlur, handleBlur,
isSubmitting,
} = useFormik<Values>({ } = useFormik<Values>({
initialValues: { text: "" }, initialValues: { text },
validationSchema, validationSchema,
onSubmit: submit, onSubmit: submit,
}); });
@ -85,7 +88,7 @@ const NoteCreationForm: FC<NoteCreationFormProps> = ({
Отмена Отмена
</Button> </Button>
<Button size="mini" type="submit" color="gray"> <Button size="mini" type="submit" color="gray" loading={isSubmitting}>
ОК ОК
</Button> </Button>
</Group> </Group>

View file

@ -1,48 +1,49 @@
import { FlowDisplayVariant, INode } from '~/types'; import { FlowDisplayVariant, INode } from "~/types";
export const URLS = { export const URLS = {
BASE: '/', BASE: "/",
LAB: '/lab', LAB: "/lab",
BORIS: '/boris', BORIS: "/boris",
AUTH: { AUTH: {
LOGIN: '/auth/login', LOGIN: "/auth/login",
}, },
EXAMPLES: { EXAMPLES: {
EDITOR: '/examples/edit', EDITOR: "/examples/edit",
IMAGE: '/examples/image', IMAGE: "/examples/image",
}, },
ERRORS: { ERRORS: {
NOT_FOUND: '/lost', NOT_FOUND: "/lost",
BACKEND_DOWN: '/oopsie', BACKEND_DOWN: "/oopsie",
}, },
NODE_URL: (id: INode['id'] | string) => `/post${id}`, NODE_URL: (id: INode["id"] | string) => `/post${id}`,
PROFILE_PAGE: (username: string) => `/profile/${username}`, PROFILE_PAGE: (username: string) => `/profile/${username}`,
SETTINGS: { SETTINGS: {
BASE: '/settings', BASE: "/settings",
NOTES: '/settings/notes', NOTES: "/settings/notes",
TRASH: '/settings/trash', TRASH: "/settings/trash",
}, },
NOTES: '/notes/', NOTES: "/notes/",
NOTE: (id: number) => `/notes/${id}`,
}; };
export const ImagePresets = { export const ImagePresets = {
'1600': '1600', "1600": "1600",
'600': '600', "600": "600",
'300': '300', "300": "300",
cover: 'cover', cover: "cover",
small_hero: 'small_hero', small_hero: "small_hero",
avatar: 'avatar', avatar: "avatar",
flow_square: 'flow_square', flow_square: "flow_square",
flow_vertical: 'flow_vertical', flow_vertical: "flow_vertical",
flow_horizontal: 'flow_horizontal', flow_horizontal: "flow_horizontal",
} as const; } as const;
export const flowDisplayToPreset: Record< export const flowDisplayToPreset: Record<
FlowDisplayVariant, FlowDisplayVariant,
typeof ImagePresets[keyof typeof ImagePresets] typeof ImagePresets[keyof typeof ImagePresets]
> = { > = {
single: 'flow_square', single: "flow_square",
quadro: 'flow_square', quadro: "flow_square",
vertical: 'flow_vertical', vertical: "flow_vertical",
horizontal: 'flow_horizontal', horizontal: "flow_horizontal",
}; };

View file

@ -1,10 +1,11 @@
import { FC, useState, VFC } from "react"; import { FC, useCallback, useState, VFC } from "react";
import { Filler } from "~/components/containers/Filler"; import { Filler } from "~/components/containers/Filler";
import { Group } from "~/components/containers/Group"; import { Group } from "~/components/containers/Group";
import { Button } from "~/components/input/Button"; import { Button } from "~/components/input/Button";
import { NoteCard } from "~/components/notes/NoteCard"; import { NoteCard } from "~/components/notes/NoteCard";
import { NoteCreationForm } from "~/components/notes/NoteCreationForm"; import { NoteCreationForm } from "~/components/notes/NoteCreationForm";
import { useConfirmation } from "~/hooks/dom/useConfirmation";
import { NoteProvider, useNotesContext } from "~/utils/providers/NoteProvider"; import { NoteProvider, useNotesContext } from "~/utils/providers/NoteProvider";
import styles from "./styles.module.scss"; import styles from "./styles.module.scss";
@ -12,12 +13,22 @@ import styles from "./styles.module.scss";
interface SettingsNotesProps {} interface SettingsNotesProps {}
const List = () => { const List = () => {
const { notes } = useNotesContext(); const { notes, remove, update } = useNotesContext();
const confirm = useConfirmation();
const onRemove = useCallback(
async (id: number) => {
confirm("Удалить? Это удалит заметку навсегда", () => remove(id));
},
[remove],
);
return ( return (
<> <>
{notes.map(note => ( {notes.map(note => (
<NoteCard <NoteCard
remove={() => onRemove(note.id)}
update={(text, callback) => update(note.id, text, callback)}
key={note.id} key={note.id}
content={note.content} content={note.content}
createdAt={note.created_at} createdAt={note.created_at}
@ -28,7 +39,7 @@ const List = () => {
}; };
const Form: FC<{ onCancel: () => void }> = ({ onCancel }) => { const Form: FC<{ onCancel: () => void }> = ({ onCancel }) => {
const { submit } = useNotesContext(); const { create: submit } = useNotesContext();
return <NoteCreationForm onSubmit={submit} onCancel={onCancel} />; return <NoteCreationForm onSubmit={submit} onCancel={onCancel} />;
}; };

View file

@ -1,21 +1,29 @@
import React, { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, {
ChangeEvent,
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { TagAutocomplete } from '~/components/tags/TagAutocomplete'; import { TagAutocomplete } from "~/components/tags/TagAutocomplete";
import { TagWrapper } from '~/components/tags/TagWrapper'; import { TagWrapper } from "~/components/tags/TagWrapper";
import { useTagAutocomplete } from '~/hooks/tag/useTagAutocomplete'; import { useTagAutocomplete } from "~/hooks/tag/useTagAutocomplete";
import styles from './styles.module.scss'; import styles from "./styles.module.scss";
const placeholder = 'Добавить'; const placeholder = "Добавить";
const prepareInput = (input: string): string[] => { const prepareInput = (input: string): string[] => {
return input return input
.split(',') .split(",")
.map((title: string) => .map((title: string) =>
title title
.trim() .trim()
.substring(0, 64) .substring(0, 64)
.toLowerCase() .toLowerCase(),
) )
.filter(el => el.length > 0); .filter(el => el.length > 0);
}; };
@ -29,7 +37,7 @@ interface IProps {
const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => { const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const wrapper = useRef<HTMLDivElement>(null); const wrapper = useRef<HTMLDivElement>(null);
const options = useTagAutocomplete(input, exclude); const options = useTagAutocomplete(input, exclude);
@ -37,7 +45,7 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const onInput = useCallback( const onInput = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => { ({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (!value.trim()) { if (!value.trim()) {
setInput(value || ''); setInput(value || "");
return; return;
} }
@ -47,36 +55,35 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
onAppend(items.slice(0, items.length - 1)); onAppend(items.slice(0, items.length - 1));
} }
setInput(items[items.length - 1] || ''); setInput(items[items.length - 1] || "");
}, },
[onAppend] [onAppend],
); );
const onKeyDown = useCallback( const onKeyDown = useCallback(
({ key }) => { ({ key }) => {
if (key === 'Escape' && ref.current) { if (key === "Escape" && ref.current) {
setInput(''); setInput("");
ref.current.blur(); ref.current.blur();
return; return;
} }
if (key === 'Backspace' && input === '') { if (key === "Backspace" && input === "") {
setInput(onClearTag() || ''); setInput(onClearTag() || "");
return; return;
} }
if (key === ',' || key === 'Comma') { if (key === "," || key === "Comma") {
const created = prepareInput(input); const created = prepareInput(input);
if (created.length) { if (created.length) {
console.log('appending?!!')
onAppend(created); onAppend(created);
} }
setInput(''); setInput("");
} }
}, },
[input, setInput, onClearTag, onAppend] [input, setInput, onClearTag, onAppend],
); );
const onFocus = useCallback(() => setFocused(true), []); const onFocus = useCallback(() => setFocused(true), []);
@ -94,39 +101,45 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
setFocused(false); setFocused(false);
if (input.trim()) { if (input.trim()) {
setInput(''); setInput("");
} }
onSubmit([]); onSubmit([]);
}, },
[input, setInput, onSubmit] [input, setInput, onSubmit],
); );
const onAutocompleteSelect = useCallback( const onAutocompleteSelect = useCallback(
(val: string) => { (val: string) => {
setInput(''); setInput("");
if (!val.trim()) { if (!val.trim()) {
return; return;
} }
onAppend([val]); onAppend([val]);
}, },
[onAppend, setInput] [onAppend, setInput],
); );
const feature = useMemo(() => (input?.substr(0, 1) === '/' ? 'green' : ''), [input]); const feature = useMemo(() => (input?.substr(0, 1) === "/" ? "green" : ""), [
input,
]);
useEffect(() => { useEffect(() => {
if (!focused) return; if (!focused) return;
document.addEventListener('click', onBlur); document.addEventListener("click", onBlur);
return () => document.removeEventListener('click', onBlur); return () => document.removeEventListener("click", onBlur);
}, [onBlur, focused]); }, [onBlur, focused]);
return ( return (
<div className={styles.wrap} ref={wrapper}> <div className={styles.wrap} ref={wrapper}>
<TagWrapper title={input || placeholder} hasInput={true} feature={feature}> <TagWrapper
title={input || placeholder}
hasInput={true}
feature={feature}
>
<input <input
type="text" type="text"
value={input} value={input}

View file

@ -0,0 +1,11 @@
import { useCallback } from "react";
export const useConfirmation = () =>
useCallback((prompt = "", onApprove: () => {}, onReject?: () => {}) => {
if (!window.confirm(prompt || "Уверен?")) {
onReject?.();
return;
}
onApprove();
}, []);

View file

@ -1,14 +1,17 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite, { SWRInfiniteKeyLoader } from "swr/infinite";
import { SWRInfiniteKeyLoader } from "swr/infinite";
import { apiGetNotes, apiPostNote } from "~/api/notes"; import {
apiCreateNote,
apiDeleteNote,
apiListNotes,
apiUpdateNote,
} from "~/api/notes";
import { ApiGetNotesRequest } from "~/api/notes/types"; import { ApiGetNotesRequest } from "~/api/notes/types";
import { useAuth } from "~/hooks/auth/useAuth"; import { useAuth } from "~/hooks/auth/useAuth";
import { GetLabNodesRequest, ILabNode } from "~/types/lab"; import { GetLabNodesRequest, ILabNode } from "~/types/lab";
import { Note } from "~/types/notes"; import { Note } from "~/types/notes";
import { showErrorToast } from "~/utils/errors/showToast";
import { flatten, uniqBy } from "~/utils/ramda"; import { flatten, uniqBy } from "~/utils/ramda";
const DEFAULT_COUNT = 20; const DEFAULT_COUNT = 20;
@ -43,7 +46,7 @@ export const useNotes = (search: string) => {
const { data, isValidating, size, setSize, mutate } = useSWRInfinite( const { data, isValidating, size, setSize, mutate } = useSWRInfinite(
getKey(isUser, search), getKey(isUser, search),
async (key: string) => { async (key: string) => {
const result = await apiGetNotes(parseKey(key)); const result = await apiListNotes(parseKey(key));
return result.list; return result.list;
}, },
{ {
@ -51,17 +54,44 @@ export const useNotes = (search: string) => {
}, },
); );
const submit = useCallback( const create = useCallback(
async (text: string, onSuccess: (note: Note) => void) => { async (text: string, onSuccess?: (note: Note) => void) => {
const result = await apiPostNote({ text }); const result = await apiCreateNote({ text });
if (data) { if (data) {
mutate(data?.map((it, index) => (index === 0 ? [result, ...it] : it))); await mutate(
data?.map((it, index) => (index === 0 ? [result, ...it] : it)),
{ revalidate: false },
);
} }
onSuccess(result); onSuccess?.(result);
}, },
[], [mutate, data],
);
const remove = useCallback(
async (id: number, onSuccess?: () => void) => {
await apiDeleteNote(id);
await mutate(
data?.map(page => page.filter(it => it.id !== id)),
{ revalidate: false },
);
onSuccess?.();
},
[mutate, data],
);
const update = useCallback(
async (id: number, text: string, onSuccess?: () => void) => {
const result = await apiUpdateNote({ id, text });
await mutate(
data?.map(page => page.map(it => (it.id === id ? result : it))),
{ revalidate: false },
);
onSuccess?.();
},
[mutate, data],
); );
const notes = useMemo(() => uniqBy(n => n.id, flatten(data || [])), [data]); const notes = useMemo(() => uniqBy(n => n.id, flatten(data || [])), [data]);
@ -74,8 +104,10 @@ export const useNotes = (search: string) => {
hasMore, hasMore,
loadMore, loadMore,
isLoading: !data && isValidating, isLoading: !data && isValidating,
submit, create,
remove,
update,
}), }),
[notes, hasMore, loadMore, data, isValidating, submit], [notes, hasMore, loadMore, data, isValidating, create, remove],
); );
}; };

View file

@ -7,7 +7,9 @@ const NoteContext = createContext<ReturnType<typeof useNotes>>({
hasMore: false, hasMore: false,
loadMore: async () => Promise.resolve(undefined), loadMore: async () => Promise.resolve(undefined),
isLoading: false, isLoading: false,
submit: () => Promise.resolve(), create: () => Promise.resolve(),
remove: () => Promise.resolve(),
update: (id: number, text: string) => Promise.resolve(),
}); });
export const NoteProvider: FC = ({ children }) => { export const NoteProvider: FC = ({ children }) => {

File diff suppressed because one or more lines are too long