This commit is contained in:
Michael Zhang 2024-05-27 14:44:24 -05:00
parent 4f31d10da4
commit 52ae6ce480
23 changed files with 530 additions and 155 deletions

11
README.md Normal file
View file

@ -0,0 +1,11 @@
panorama
========
Personal information manager.
Contact
-------
Author: Michael Zhang
License: GPL-3.0-only

View file

@ -22,6 +22,7 @@
"@uiw/react-md-editor": "^4.0.4",
"classnames": "^2.5.1",
"date-fns": "^3.6.0",
"formik": "^2.4.6",
"hast-util-to-jsx-runtime": "^2.3.0",
"hast-util-to-mdast": "^10.1.0",
"immutable": "^4.3.6",

View file

@ -4,12 +4,12 @@
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -40,9 +40,9 @@ function App() {
})();
}, [nodesOpened, openNode]);
const nodes = nodesOpened
.reverse()
.map((nodeId) => <NodeDisplay key={nodeId} id={nodeId} />);
const nodes = [...nodesOpened.reverse().values()].map((nodeId, idx) => (
<NodeDisplay idx={idx} key={nodeId} id={nodeId} />
));
return (
<QueryClientProvider client={queryClient}>

View file

@ -5,13 +5,13 @@ import { getVersion } from "@tauri-apps/api/app";
import ListIcon from "@mui/icons-material/List";
import { useSetAtom } from "jotai";
import { sidebarExpandedAtom } from "./Sidebar";
import { nodesOpenedAtom } from "../App";
import { nodesOpenedAtom, useOpenNode } from "../App";
import { useCallback } from "react";
const version = await getVersion();
export default function Header() {
const setNodesOpened = useSetAtom(nodesOpenedAtom);
const openNode = useOpenNode();
const setSidebarExpanded = useSetAtom(sidebarExpandedAtom);
const createNewJournalPage = useCallback(() => {
@ -29,9 +29,9 @@ export default function Header() {
}),
});
const data = await resp.json();
setNodesOpened((prev) => [data.node_id, ...prev]);
openNode(data.node_id);
})();
}, [setNodesOpened]);
}, [openNode]);
return (
<>

View file

@ -14,17 +14,44 @@
}
.header {
padding: 2px 12px;
color: rgb(106, 103, 160);
background: rgb(204, 201, 255);
background: linear-gradient(90deg, rgba(204, 201, 255, 1) 0%, rgba(255, 255, 255, 1) 100%);
font-size: 0.6rem;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px;
button {
background-color: transparent;
border: none;
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
&:hover {
background-color: rgba(0, 0, 0, 0.25);
}
}
span {
padding: 2px 0;
}
}
.footer {
padding: 2px 12px;
font-size: 0.6rem;
align-self: flex-start;
// For the resize handle
// TODO: Make the entire right side resize and then remove this
width: calc(100% - 20px);
// border: 1px solid red;
// box-sizing: border-box;
}
.body {
@ -35,15 +62,3 @@
display: flex;
flex-direction: column;
}
.title {
padding: 12px;
border: none;
border-bottom: 1px solid lightgray;
outline: none;
font-size: 1.2em;
}
.untitled {
color: gray;
}

View file

@ -2,88 +2,44 @@ import { useQuery } from "@tanstack/react-query";
import styles from "./NodeDisplay.module.scss";
import ReactTimeAgo from "react-time-ago";
import JournalPage from "./nodes/JournalPage";
import { useCallback, useEffect, useState } from "react";
import { getNode } from "../lib/getNode";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import { useOpenNode } from "../App";
export interface NodeDisplayProps {
id: string;
idx?: number | undefined;
}
export default function NodeDisplay({ id }: NodeDisplayProps) {
export default function NodeDisplay({ id, idx }: NodeDisplayProps) {
const query = useQuery({
queryKey: ["fetchNode", id],
queryFn: async () => {
const resp = await fetch(`http://localhost:5195/node/${id}`);
const json = await resp.json();
return json;
},
queryFn: getNode,
});
const { isSuccess, status, data } = query;
const { isSuccess, status, data: nodeDescriptor } = query;
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [title, setTitle] = useState(() =>
isSuccess && data ? data.title : undefined,
);
useEffect(() => {
if (data) {
setTitle(data.title);
}
}, [data]);
const saveChangedTitle = useCallback(() => {
(async () => {
const resp = await fetch(`http://localhost:5195/node/${id}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title }),
});
setIsEditingTitle(false);
})();
}, [title, id]);
let Component = undefined;
let data = undefined;
if (isSuccess) {
Component = nodeDescriptor.render;
data = nodeDescriptor.data;
}
return (
<div className={styles.container}>
<div className={styles.header}>
{isSuccess ? (
<NodeDisplayHeaderLoaded id={id} data={data} />
<NodeDisplayHeaderLoaded idx={idx} id={id} data={data} />
) : (
<>ID {id}</>
<>
ID {id} ({status})
</>
)}
</div>
{isEditingTitle ? (
<form
onSubmit={(evt) => {
evt.preventDefault();
saveChangedTitle();
}}
>
<input
className={styles.title}
type="text"
value={title}
onChange={(evt) => setTitle(evt.target.value)}
onBlur={() => saveChangedTitle()}
// biome-ignore lint/a11y/noAutofocus: <explanation>
autoFocus
/>
</form>
) : (
<div
className={styles.title}
onDoubleClick={() => setIsEditingTitle(true)}
>
{title ?? <span className={styles.untitled}>(untitled)</span>}
</div>
)}
<div className={styles.body}>
{isSuccess ? (
<NodeDisplayLoaded id={id} data={data} />
) : (
<>Status: {status}</>
)}
{Component && <Component id={id} data={data} />}
</div>
<div className={styles.footer}>{id}</div>
@ -91,25 +47,27 @@ export default function NodeDisplay({ id }: NodeDisplayProps) {
);
}
function NodeDisplayHeaderLoaded({ id, data }) {
function NodeDisplayHeaderLoaded({ idx, id, data }) {
const openNode = useOpenNode();
return (
<>
Type {data.type} &middot; Last updated{" "}
<ReactTimeAgo date={data.created_at * 1000} />
{idx === 0 || (
<button
type="button"
onClick={() => openNode(id)}
title="Move node to the left"
>
<FirstPageIcon fontSize="inherit" />
</button>
)}
<span>
Type {data.type}{" "}
{data.created_at && (
<>
&middot; Last updated <ReactTimeAgo date={data.updated_at * 1000} />
</>
)}
</span>
</>
);
}
function NodeDisplayLoaded({ id, data }) {
switch (data.type) {
case "panorama/journal/page":
return <JournalPage id={id} data={data} />;
default:
return (
<>
Don't know how to render node of type <code>{data.type}</code>
</>
);
}
}

View file

@ -93,21 +93,23 @@ function SearchMenu({ results }) {
return (
<div className={styles.searchResults}>
{results.map((result) => (
<button
type="button"
key={result.node_id}
className={styles.searchResult}
onClick={() => {
setSearchQuery("");
setShowMenu(false);
openNode(result.node_id);
}}
>
<div className={styles.title}>{result.title}</div>
<div className={styles.subtitle}>{result.content}</div>
</button>
))}
{results.map((result) => {
return (
<button
type="button"
key={result.node_id}
className={styles.searchResult}
onClick={() => {
setSearchQuery("");
setShowMenu(false);
openNode(result.node_id);
}}
>
<div className={styles.title}>{result.title}</div>
<div className={styles.subtitle}>{result.content}</div>
</button>
);
})}
</div>
);
}

View file

@ -12,6 +12,8 @@
padding: 6px;
font-size: 0.95em;
border-radius: 4px;
border: none;
background-color: unset;
&:hover {
background-color: rgba(255, 255, 255, 0.2);

View file

@ -3,11 +3,13 @@ import styles from "./Sidebar.module.scss";
import classNames from "classnames";
import EmailIcon from "@mui/icons-material/Email";
import SettingsIcon from "@mui/icons-material/Settings";
import { useOpenNode } from "../App";
export const sidebarExpandedAtom = atom(false);
export default function Sidebar() {
const sidebarExpanded = useAtomValue(sidebarExpandedAtom);
const openNode = useOpenNode();
return (
<div
@ -16,10 +18,14 @@ export default function Sidebar() {
sidebarExpanded ? styles.expanded : styles.collapsed,
)}
>
<div className={styles.item}>
<button
type="button"
className={styles.item}
onClick={() => openNode("panorama/mail")}
>
<EmailIcon />
<span className={styles.label}>Email</span>
</div>
</button>
<div className="spacer" />

View file

@ -4,6 +4,18 @@
flex-direction: column;
}
.title {
padding: 12px;
border: none;
border-bottom: 1px solid lightgray;
outline: none;
font-size: 1.2em;
}
.untitled {
color: gray;
}
.mdContent {
flex-grow: 1;
display: flex;

View file

@ -1,16 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import MDEditor, { PreviewType } from "@uiw/react-md-editor";
import { usePrevious, useDebounce } from "@uidotdev/usehooks";
import { usePrevious } from "@uidotdev/usehooks";
import { useQueryClient } from "@tanstack/react-query";
import styles from "./JournalPage.module.scss";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import remarkEmbedder from "@remark-embedder/core";
import { parse as parseDate, format as formatDate } from "date-fns";
import { useDebounce } from "use-debounce";
export interface JournalPageProps {
id: string;
data: {
day?: string;
title?: string;
content: string;
};
}
@ -19,13 +21,19 @@ export default function JournalPage({ id, data }: JournalPageProps) {
const { day } = data;
const queryClient = useQueryClient();
const [value, setValue] = useState(() => data.content);
const valueToSave = useDebounce(value, 1000);
const [valueToSave] = useDebounce(value, 1000, {
leading: true,
trailing: true,
});
const previous = usePrevious(valueToSave);
const changed = valueToSave !== previous;
const [mode, setMode] = useState<PreviewType>("preview");
const [title, setTitle] = useState(() => data.title);
const [isEditingTitle, setIsEditingTitle] = useState(false);
useEffect(() => {
if (changed) {
// console.log("OLD", previous, "NEW", valueToSave);
(async () => {
console.log("Saving...");
const resp = await fetch(`http://localhost:5195/node/${id}`, {
@ -47,23 +55,63 @@ export default function JournalPage({ id, data }: JournalPageProps) {
}
}, [id, changed, valueToSave, queryClient]);
return (
<div data-color-mode="light" className={styles.container}>
{day && <DayIndicator day={day} />}
const saveChangedTitle = useCallback(() => {
(async () => {
const resp = await fetch(`http://localhost:5195/node/${id}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title }),
});
setIsEditingTitle(false);
})();
}, [title, id]);
<MDEditor
value={value}
className={styles.mdEditor}
onChange={(newValue) => newValue !== undefined && setValue(newValue)}
preview={mode}
visibleDragbar={false}
onDoubleClick={() => setMode("live")}
previewOptions={{
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
}}
/>
</div>
return (
<>
{isEditingTitle ? (
<form
onSubmit={(evt) => {
evt.preventDefault();
saveChangedTitle();
}}
>
<input
className={styles.title}
type="text"
value={title}
onChange={(evt) => setTitle(evt.target.value)}
onBlur={() => saveChangedTitle()}
// biome-ignore lint/a11y/noAutofocus: <explanation>
autoFocus
/>
</form>
) : (
<div
className={styles.title}
onDoubleClick={() => setIsEditingTitle(true)}
>
{title ?? <span className={styles.untitled}>(untitled)</span>}
</div>
)}
<div data-color-mode="light" className={styles.container}>
{day && <DayIndicator day={day} />}
<MDEditor
value={value}
className={styles.mdEditor}
onChange={(newValue) => newValue !== undefined && setValue(newValue)}
preview={mode}
visibleDragbar={false}
onDoubleClick={() => setMode("live")}
previewOptions={{
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
}}
/>
</div>
</>
);
}

View file

@ -0,0 +1,25 @@
.container {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
padding: 2px 12px;
.title {
font-size: 1rem;
}
}
.settings {
h2 {
margin: 0;
}
}
.mailList {
flex-grow: 1;
border-top: 1px solid lightgray;
}

View file

@ -0,0 +1,134 @@
import { useCallback, useState } from "react";
import styles from "./Mail.module.scss";
import { Formik } from "formik";
import SettingsIcon from "@mui/icons-material/Settings";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export default function Mail() {
const [showSettings, setShowSettings] = useState(false);
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}>
{showSettings ? <>Settings</> : <>Mail</>}
</div>
<div className="spacer" />
<button type="button" onClick={() => setShowSettings((prev) => !prev)}>
<SettingsIcon />
</button>
</div>
{showSettings && (
<div className={styles.settings}>
<MailConfig />
</div>
)}
<div className={styles.mailList}></div>
</div>
);
}
function MailConfig() {
const queryClient = useQueryClient();
const config = useQuery({
queryKey: ["mailConfigs"],
queryFn: fetchMailConfig,
});
const { isSuccess, data } = config;
return (
<>
<div>
Add a new config
<Formik
onSubmit={(values) => {
(async () => {
const resp = await fetch("http://localhost:5195/node", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "panorama/mail/config",
extra_data: {
"panorama/mail/config/imap_hostname": values.imapHostname,
"panorama/mail/config/imap_port": values.imapPort,
"panorama/mail/config/imap_username": values.imapUsername,
"panorama/mail/config/imap_password": values.imapPassword,
},
}),
});
const data = await resp.json();
console.log("result", data);
queryClient.invalidateQueries({ queryKey: ["mailConfigs"] });
})();
}}
initialValues={{
imapHostname: "",
imapPort: 993,
imapUsername: "",
imapPassword: "",
}}
>
{({ values, handleSubmit, handleChange, handleBlur }) => (
<form onSubmit={handleSubmit}>
<input
type="text"
name="imapHostname"
placeholder="IMAP Hostname"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapHostname}
/>
<input
type="number"
name="imapPort"
placeholder="IMAP Port"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapPort}
/>
<br />
<input
type="text"
name="imapUsername"
placeholder="IMAP Username"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapUsername}
/>
<input
type="password"
name="imapPassword"
placeholder="IMAP Password"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapPassword}
/>
<button type="submit">Add</button>
</form>
)}
</Formik>
</div>
<div>
{isSuccess && (
<ul>
{data.map((config) => (
<li>{JSON.stringify(config)}</li>
))}
</ul>
)}
</div>
</>
);
}
async function fetchMailConfig() {
const resp = await fetch("http://localhost:5195/mail/config");
const data = await resp.json();
return data.configs;
}

View file

@ -17,3 +17,7 @@ html,
.spacer {
flex-grow: 1;
}
* {
box-sizing: border-box;
}

34
app/src/lib/getNode.ts Normal file
View file

@ -0,0 +1,34 @@
import { Component, FC } from "react";
import JournalPage from "../components/nodes/JournalPage";
import Mail from "../components/nodes/Mail";
import { QueryFunctionContext } from "@tanstack/react-query";
export interface RenderProps {
id: string;
data: any;
}
export interface NodeDescriptor {
render: FC<RenderProps>;
data: {
type: string;
} & any;
}
export async function getNode({
queryKey,
}: QueryFunctionContext): Promise<NodeDescriptor> {
const [, node_id] = queryKey;
switch (node_id) {
case "panorama/mail":
return { data: { type: "panorama/mail" }, render: Mail };
default: {
const resp = await fetch(`http://localhost:5195/node/${node_id}`);
const data = await resp.json();
return {
data: { ...data, type: "panorama/journal/page" },
render: JournalPage,
};
}
}
}

View file

@ -47,8 +47,6 @@ pub async fn export(State(state): State<AppState>) -> AppResult<()> {
relation_columns.insert(relation_name.clone(), columns);
}
println!("columns: {relation_columns:?}");
let base_dir = PathBuf::from("export");
fs::create_dir_all(&base_dir);
@ -68,7 +66,6 @@ pub async fn export(State(state): State<AppState>) -> AppResult<()> {
.join(", ");
let query = format!("?[{columns}] := *{relation_name} {{ {columns} }}");
println!("Query: {query}");
let result = tx.run_script(&query, Default::default())?;
writer.write_record(result.headers).unwrap();

View file

@ -10,7 +10,6 @@ pub async fn get_todays_journal_id(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let today = todays_date();
println!("Getting journal id for {today}!");
let result = state.db.run_script(
"
@ -22,8 +21,6 @@ pub async fn get_todays_journal_id(
ScriptMutability::Immutable,
)?;
println!("Result: {:?}", result);
// TODO: Do this check on the server side
if result.rows.len() == 0 {
// Insert a new one

View file

@ -0,0 +1,53 @@
use axum::{extract::State, Json};
use cozo::{DbInstance, ScriptMutability};
use serde_json::Value;
use crate::{error::AppResult, AppState};
pub async fn get_mail_config(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let configs = fetch_mail_configs(&state.db)?;
Ok(Json(json!({
"configs": configs,
})))
}
pub async fn mail_loop(db: DbInstance) {
// Fetch the mail configs
}
#[derive(Serialize)]
struct MailConfig {
node_id: String,
imap_hostname: String,
imap_port: u16,
imap_username: String,
imap_password: String,
}
fn fetch_mail_configs(db: &DbInstance) -> AppResult<Vec<MailConfig>> {
let result = db.run_script(
"
?[node_id, imap_hostname, imap_port, imap_username, imap_password] :=
*node{ id: node_id },
*mail_config{ node_id, imap_hostname, imap_port, imap_username, imap_password }
",
Default::default(),
ScriptMutability::Immutable,
)?;
let result = result
.rows
.into_iter()
.map(|row| MailConfig {
node_id: row[0].get_str().unwrap().to_owned(),
imap_hostname: row[1].get_str().unwrap().to_owned(),
imap_port: row[2].get_int().unwrap() as u16,
imap_username: row[3].get_str().unwrap().to_owned(),
imap_password: row[4].get_str().unwrap().to_owned(),
})
.collect::<Vec<_>>();
Ok(result)
}

View file

@ -10,6 +10,7 @@ extern crate sugars;
mod error;
mod export;
mod journal;
mod mail;
mod migrations;
mod node;
mod query_builder;
@ -31,6 +32,7 @@ use tower_http::cors::{self, CorsLayer};
use crate::{
export::export,
journal::get_todays_journal_id,
mail::{get_mail_config, mail_loop},
migrations::run_migrations,
node::{create_node, get_node, node_types, search_nodes, update_node},
};
@ -56,6 +58,8 @@ async fn main() -> Result<()> {
run_migrations(&db).await?;
tokio::spawn(mail_loop(db.clone()));
let state = AppState { db };
let cors = CorsLayer::new()
@ -73,6 +77,7 @@ async fn main() -> Result<()> {
.route("/node/:id", post(update_node))
.route("/node/types", get(node_types))
.route("/journal/get_todays_journal_id", get(get_todays_journal_id))
.route("/mail/config", get(get_mail_config))
.layer(ServiceBuilder::new().layer(cors))
.with_state(state);
@ -88,7 +93,8 @@ pub fn ensure_ok(s: &str) -> Result<()> {
let status = status.as_object().unwrap();
let ok = status.get("ok").unwrap().as_bool().unwrap_or(false);
if !ok {
bail!("shit (error: {s})")
let display = status.get("display").unwrap().as_str().unwrap();
bail!("shit (error: {display})")
}
Ok(())
}

View file

@ -51,6 +51,7 @@ pub async fn run_migrations(db: &DbInstance) -> Result<()> {
&format!("{{\"version\":{}}}", idx),
false,
);
ensure_ok(&result)?;
println!("succeeded migration {idx}!");
@ -128,7 +129,11 @@ fn migration_01(db: &DbInstance) -> Result<()> {
}
{
?[key, relation, field_name, type] <- [
['panorama/journal/page/content', 'journal', 'content', 'string']
['panorama/journal/page/content', 'journal', 'content', 'string'],
['panorama/mail/config/imap_hostname', 'mail_config', 'imap_hostname', 'string'],
['panorama/mail/config/imap_port', 'mail_config', 'imap_port', 'int'],
['panorama/mail/config/imap_username', 'mail_config', 'imap_username', 'string'],
['panorama/mail/config/imap_password', 'mail_config', 'imap_password', 'string'],
]
:put fqkey_to_dbkey { key, relation, field_name, type }
}
@ -144,6 +149,20 @@ fn migration_01(db: &DbInstance) -> Result<()> {
filters: [Lowercase, Stemmer('english'), Stopwords('en')],
}
}
# Mail
{
:create mail_config {
node_id: String
=>
imap_hostname: String,
imap_port: Int,
imap_username: String,
imap_password: String,
}
}
# Calendar
",
"",
false,

View file

@ -70,7 +70,6 @@ pub async fn update_node(
Path(node_id): Path<String>,
Json(update_data): Json<UpdateData>,
) -> AppResult<Json<Value>> {
println!("Update data: {:?}", update_data);
let node_id_data = DataValue::from(node_id.clone());
// TODO: Combine these into the same script
@ -158,7 +157,6 @@ pub async fn create_node(
) -> AppResult<Json<Value>> {
let node_id = Uuid::now_v7();
let node_id = node_id.to_string();
println!("Opts: {opts:?}");
let tx = state.db.multi_transaction(true);
@ -179,15 +177,17 @@ pub async fn create_node(
let result_by_relation = result
.iter()
.into_group_map_by(|(key, (relation, field_name, ty))| relation);
println!("Result by relation: {result_by_relation:?}");
for (relation, fields) in result_by_relation.iter() {
let fields_mapping = fields
.into_iter()
.map(|(key, (_, field_name, _))| {
.map(|(key, (_, field_name, ty))| {
let new_value = extra_data.get(*key).unwrap();
// TODO: Make this more generic
let new_value = DataValue::from(new_value.as_str().unwrap());
let new_value = match ty.as_str() {
"int" => DataValue::from(new_value.as_i64().unwrap()),
_ => DataValue::from(new_value.as_str().unwrap()),
};
(field_name.to_owned(), new_value)
})
.collect::<BTreeMap<_, _>>();

View file

@ -47,6 +47,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
formik:
specifier: ^2.4.6
version: 2.4.6(react@18.3.1)
hast-util-to-jsx-runtime:
specifier: ^2.3.0
version: 2.3.0
@ -1257,6 +1260,13 @@ packages:
'@types/unist': 3.0.2
dev: false
/@types/hoist-non-react-statics@3.3.5:
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
dependencies:
'@types/react': 18.3.3
hoist-non-react-statics: 3.3.2
dev: false
/@types/katex@0.16.7:
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
dev: false
@ -1567,6 +1577,11 @@ packages:
character-entities: 2.0.2
dev: false
/deepmerge@2.2.1:
resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
engines: {node: '>=0.10.0'}
dev: false
/dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -1674,6 +1689,22 @@ packages:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
dev: false
/formik@2.4.6(react@18.3.1):
resolution: {integrity: sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==}
peerDependencies:
react: '>=16.8.0'
dependencies:
'@types/hoist-non-react-statics': 3.3.5
deepmerge: 2.2.1
hoist-non-react-statics: 3.3.2
lodash: 4.17.21
lodash-es: 4.17.21
react: 18.3.1
react-fast-compare: 2.0.4
tiny-warning: 1.0.3
tslib: 2.6.2
dev: false
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -2101,6 +2132,14 @@ packages:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: false
/lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
dev: false
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false
/longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
dev: false
@ -2713,6 +2752,10 @@ packages:
scheduler: 0.23.2
dev: false
/react-fast-compare@2.0.4:
resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
dev: false
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
@ -3099,6 +3142,10 @@ packages:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
dev: false
/tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
@ -3122,6 +3169,10 @@ packages:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
dev: false
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false
/typescript@5.4.5:
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
engines: {node: '>=14.17'}