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 {
ApiGetNotesRequest,
ApiGetNotesRequest as ApiListNotesRequest,
ApiGetNotesResponse,
ApiPostNoteRequest,
ApiPostNoteResponse,
ApiCreateNoteRequest,
ApiUpdateNoteResponse,
ApiUpdateNoteRequest,
} from "~/api/notes/types";
import { URLS } from "~/constants/urls";
import { api, cleanResult } from "~/utils/api";
export const apiGetNotes = ({ limit, offset, search }: ApiGetNotesRequest) =>
export const apiListNotes = ({ limit, offset, search }: ApiListNotesRequest) =>
api
.get<ApiGetNotesResponse>(URLS.NOTES, { params: { limit, offset, search } })
.then(cleanResult);
export const apiPostNote = ({ text }: ApiPostNoteRequest) =>
export const apiCreateNote = ({ text }: ApiCreateNoteRequest) =>
api
.post<ApiPostNoteResponse>(URLS.NOTES, {
.post<ApiUpdateNoteResponse>(URLS.NOTES, {
text,
})
.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;
}
export interface ApiPostNoteRequest {
export interface ApiCreateNoteRequest {
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 { Markdown } from "~/components/containers/Markdown";
@ -6,25 +6,59 @@ import { Padder } from "~/components/containers/Padder";
import { NoteMenu } from "~/components/notes/NoteMenu";
import { formatText, getPrettyDate } from "~/utils/dom";
import { NoteCreationForm } from "../NoteCreationForm";
import styles from "./styles.module.scss";
interface NoteCardProps {
content: string;
remove: () => Promise<void>;
update: (text: string, callback?: () => void) => Promise<void>;
createdAt: string;
}
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) }}
/>
</Padder>
const NoteCard: VFC<NoteCardProps> = ({
content,
createdAt,
remove,
update,
}) => {
const [editing, setEditing] = useState(false);
<Padder className={styles.footer}>{getPrettyDate(createdAt)}</Padder>
</Card>
);
const toggleEditing = useCallback(() => setEditing(v => !v), []);
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 };

View file

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

View file

@ -1,48 +1,49 @@
import { FlowDisplayVariant, INode } from '~/types';
import { FlowDisplayVariant, INode } from "~/types";
export const URLS = {
BASE: '/',
LAB: '/lab',
BORIS: '/boris',
BASE: "/",
LAB: "/lab",
BORIS: "/boris",
AUTH: {
LOGIN: '/auth/login',
LOGIN: "/auth/login",
},
EXAMPLES: {
EDITOR: '/examples/edit',
IMAGE: '/examples/image',
EDITOR: "/examples/edit",
IMAGE: "/examples/image",
},
ERRORS: {
NOT_FOUND: '/lost',
BACKEND_DOWN: '/oopsie',
NOT_FOUND: "/lost",
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}`,
SETTINGS: {
BASE: '/settings',
NOTES: '/settings/notes',
TRASH: '/settings/trash',
BASE: "/settings",
NOTES: "/settings/notes",
TRASH: "/settings/trash",
},
NOTES: '/notes/',
NOTES: "/notes/",
NOTE: (id: number) => `/notes/${id}`,
};
export const ImagePresets = {
'1600': '1600',
'600': '600',
'300': '300',
cover: 'cover',
small_hero: 'small_hero',
avatar: 'avatar',
flow_square: 'flow_square',
flow_vertical: 'flow_vertical',
flow_horizontal: 'flow_horizontal',
"1600": "1600",
"600": "600",
"300": "300",
cover: "cover",
small_hero: "small_hero",
avatar: "avatar",
flow_square: "flow_square",
flow_vertical: "flow_vertical",
flow_horizontal: "flow_horizontal",
} as const;
export const flowDisplayToPreset: Record<
FlowDisplayVariant,
typeof ImagePresets[keyof typeof ImagePresets]
> = {
single: 'flow_square',
quadro: 'flow_square',
vertical: 'flow_vertical',
horizontal: 'flow_horizontal',
single: "flow_square",
quadro: "flow_square",
vertical: "flow_vertical",
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 { Group } from "~/components/containers/Group";
import { Button } from "~/components/input/Button";
import { NoteCard } from "~/components/notes/NoteCard";
import { NoteCreationForm } from "~/components/notes/NoteCreationForm";
import { useConfirmation } from "~/hooks/dom/useConfirmation";
import { NoteProvider, useNotesContext } from "~/utils/providers/NoteProvider";
import styles from "./styles.module.scss";
@ -12,12 +13,22 @@ import styles from "./styles.module.scss";
interface SettingsNotesProps {}
const List = () => {
const { notes } = useNotesContext();
const { notes, remove, update } = useNotesContext();
const confirm = useConfirmation();
const onRemove = useCallback(
async (id: number) => {
confirm("Удалить? Это удалит заметку навсегда", () => remove(id));
},
[remove],
);
return (
<>
{notes.map(note => (
<NoteCard
remove={() => onRemove(note.id)}
update={(text, callback) => update(note.id, text, callback)}
key={note.id}
content={note.content}
createdAt={note.created_at}
@ -28,7 +39,7 @@ const List = () => {
};
const Form: FC<{ onCancel: () => void }> = ({ onCancel }) => {
const { submit } = useNotesContext();
const { create: submit } = useNotesContext();
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 { TagWrapper } from '~/components/tags/TagWrapper';
import { useTagAutocomplete } from '~/hooks/tag/useTagAutocomplete';
import { TagAutocomplete } from "~/components/tags/TagAutocomplete";
import { TagWrapper } from "~/components/tags/TagWrapper";
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[] => {
return input
.split(',')
.split(",")
.map((title: string) =>
title
.trim()
.substring(0, 64)
.toLowerCase()
.toLowerCase(),
)
.filter(el => el.length > 0);
};
@ -29,7 +37,7 @@ interface IProps {
const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const [focused, setFocused] = useState(false);
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const ref = useRef<HTMLInputElement>(null);
const wrapper = useRef<HTMLDivElement>(null);
const options = useTagAutocomplete(input, exclude);
@ -37,7 +45,7 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const onInput = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (!value.trim()) {
setInput(value || '');
setInput(value || "");
return;
}
@ -47,36 +55,35 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
onAppend(items.slice(0, items.length - 1));
}
setInput(items[items.length - 1] || '');
setInput(items[items.length - 1] || "");
},
[onAppend]
[onAppend],
);
const onKeyDown = useCallback(
({ key }) => {
if (key === 'Escape' && ref.current) {
setInput('');
if (key === "Escape" && ref.current) {
setInput("");
ref.current.blur();
return;
}
if (key === 'Backspace' && input === '') {
setInput(onClearTag() || '');
if (key === "Backspace" && input === "") {
setInput(onClearTag() || "");
return;
}
if (key === ',' || key === 'Comma') {
if (key === "," || key === "Comma") {
const created = prepareInput(input);
if (created.length) {
console.log('appending?!!')
onAppend(created);
}
setInput('');
setInput("");
}
},
[input, setInput, onClearTag, onAppend]
[input, setInput, onClearTag, onAppend],
);
const onFocus = useCallback(() => setFocused(true), []);
@ -94,39 +101,45 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
setFocused(false);
if (input.trim()) {
setInput('');
setInput("");
}
onSubmit([]);
},
[input, setInput, onSubmit]
[input, setInput, onSubmit],
);
const onAutocompleteSelect = useCallback(
(val: string) => {
setInput('');
setInput("");
if (!val.trim()) {
return;
}
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(() => {
if (!focused) return;
document.addEventListener('click', onBlur);
return () => document.removeEventListener('click', onBlur);
document.addEventListener("click", onBlur);
return () => document.removeEventListener("click", onBlur);
}, [onBlur, focused]);
return (
<div className={styles.wrap} ref={wrapper}>
<TagWrapper title={input || placeholder} hasInput={true} feature={feature}>
<TagWrapper
title={input || placeholder}
hasInput={true}
feature={feature}
>
<input
type="text"
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 { SWRInfiniteKeyLoader } from "swr/infinite";
import useSWRInfinite, { 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 { 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;
@ -43,7 +46,7 @@ export const useNotes = (search: string) => {
const { data, isValidating, size, setSize, mutate } = useSWRInfinite(
getKey(isUser, search),
async (key: string) => {
const result = await apiGetNotes(parseKey(key));
const result = await apiListNotes(parseKey(key));
return result.list;
},
{
@ -51,17 +54,44 @@ export const useNotes = (search: string) => {
},
);
const submit = useCallback(
async (text: string, onSuccess: (note: Note) => void) => {
const result = await apiPostNote({ text });
const create = useCallback(
async (text: string, onSuccess?: (note: Note) => void) => {
const result = await apiCreateNote({ text });
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]);
@ -74,8 +104,10 @@ export const useNotes = (search: string) => {
hasMore,
loadMore,
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,
loadMore: async () => Promise.resolve(undefined),
isLoading: false,
submit: () => Promise.resolve(),
create: () => Promise.resolve(),
remove: () => Promise.resolve(),
update: (id: number, text: string) => Promise.resolve(),
});
export const NoteProvider: FC = ({ children }) => {

File diff suppressed because one or more lines are too long