update
This commit is contained in:
parent
4f31d10da4
commit
52ae6ce480
23 changed files with 530 additions and 155 deletions
11
README.md
Normal file
11
README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
panorama
|
||||
========
|
||||
|
||||
Personal information manager.
|
||||
|
||||
Contact
|
||||
-------
|
||||
|
||||
Author: Michael Zhang
|
||||
|
||||
License: GPL-3.0-only
|
|
@ -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",
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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 {
|
||||
|
@ -34,16 +61,4 @@
|
|||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid lightgray;
|
||||
outline: none;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.untitled {
|
||||
color: gray;
|
||||
}
|
|
@ -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} · 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 && (
|
||||
<>
|
||||
· 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
25
app/src/components/nodes/Mail.module.scss
Normal file
25
app/src/components/nodes/Mail.module.scss
Normal 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;
|
||||
}
|
134
app/src/components/nodes/Mail.tsx
Normal file
134
app/src/components/nodes/Mail.tsx
Normal 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;
|
||||
}
|
|
@ -16,4 +16,8 @@ html,
|
|||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
34
app/src/lib/getNode.ts
Normal file
34
app/src/lib/getNode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<_, _>>();
|
||||
|
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue