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:
parent
cedf0adcfa
commit
1bb08f72e6
11 changed files with 224 additions and 101 deletions
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
|
|
@ -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,17 +101,17 @@ 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;
|
||||
|
@ -112,21 +119,27 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
|
|||
|
||||
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}
|
||||
|
|
11
src/hooks/dom/useConfirmation.ts
Normal file
11
src/hooks/dom/useConfirmation.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
export const useConfirmation = () =>
|
||||
useCallback((prompt = "", onApprove: () => {}, onReject?: () => {}) => {
|
||||
if (!window.confirm(prompt || "Уверен?")) {
|
||||
onReject?.();
|
||||
return;
|
||||
}
|
||||
|
||||
onApprove();
|
||||
}, []);
|
|
@ -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],
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue