backend/frontend: add edit / update support

This change makes good progress towards having a usable
app. "Write", and "Preview" tabs have been added - in the
"Preview" tab markdown is transformed into HTML in real-time.
This commit is contained in:
0x1eef 2023-12-22 15:32:14 -03:00
parent de6b33f1aa
commit 2a0e987e0f
19 changed files with 284 additions and 175 deletions

View file

@ -14,11 +14,13 @@
"@types/luxon": "^3.3.7",
"@types/react": "^18.0.18",
"@types/react-dom": "^18.0.6",
"@types/showdown": "^2.0.6",
"eslint": "^8.26.0",
"eslint-config-prettier": "^8.5.0",
"luxon": "^3.4.4",
"prettier": "^2.7.1",
"react-hook-form": "^7.49.2",
"showdown": "^2.1.0",
"ts-loader": "^9.3.1",
"ts-standard": "^12.0.1",
"typescript": "^4.8.2",
@ -317,6 +319,12 @@
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true
},
"node_modules/@types/showdown": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.6.tgz",
"integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@ -4018,6 +4026,31 @@
"node": ">=8"
}
},
"node_modules/showdown": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
"dev": true,
"dependencies": {
"commander": "^9.0.0"
},
"bin": {
"showdown": "bin/showdown.js"
},
"funding": {
"type": "individual",
"url": "https://www.paypal.me/tiviesantos"
}
},
"node_modules/showdown/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",

View file

@ -9,11 +9,13 @@
"@types/luxon": "^3.3.7",
"@types/react": "^18.0.18",
"@types/react-dom": "^18.0.6",
"@types/showdown": "^2.0.6",
"eslint": "^8.26.0",
"eslint-config-prettier": "^8.5.0",
"luxon": "^3.4.4",
"prettier": "^2.7.1",
"react-hook-form": "^7.49.2",
"showdown": "^2.1.0",
"ts-loader": "^9.3.1",
"ts-standard": "^12.0.1",
"typescript": "^4.8.2",

View file

@ -1 +1,3 @@
$black: lighten(#000, 20%);
$gray1: #f4f0ec;
$gray2: lighten($gray1, 5%);

View file

@ -0,0 +1,41 @@
@import "colors";
.table {
.table.div {
display: flex;
padding: 10px;
width: 100%;
color: #FFF;
background: $gray1;
color: $black;
border-radius: 2px;
border: #cfcfc4 1px solid;
border-bottom: none;
}
.table.div.tabbed {
padding: 5px 5px 0 5px;
ul.tabs {
list-style-type: none;
display: flex;
height: 100%;
margin: 0;
padding: 0;
li {
height: 100%;
border: 1px solid #cfcfc4;
border-bottom: none;
padding: 10px;
background: #FFF;
margin-right: 10px;
border-radius: 5px;
}
}
}
.table.content {
background: $gray2;
padding: 10px;
border: #cfcfc4 1px solid;
}
}

View file

@ -1,6 +1,7 @@
@import "fonts";
@import "colors";
@import "forms";
@import "tables";
*, *:before, *:after {
box-sizing: border-box;
@ -11,60 +12,58 @@ html, html body, .root {
}
body {
font-family: "SF Mono","Segoe UI Mono","Roboto Mono",Menlo,Courier,monospace;
font-weight: normal;
font-family: "Noto Sans Serif";
}
.root {
margin: 0 auto;
color: $black;
width: 75%;
padding: 20px 0 20px 0;
max-width: 1024px;
.navbar {
display: flex;
justify-content: space-between;
width: 100%;
padding: 10px 0px 10px 0px;
}
form {
margin: 0 auto;
height: 100%;
width: 80%;
}
.textarea {
margin: 0 0 10px 0;
height: 50%;
textarea { height: 100%; }
.root {
.issue {
.content {
padding: 10px;
}
.content, textarea {
min-height: 350px;
}
}
ul {
margin: 0;
padding: 0;
list-style-type: none;
li {
margin: 0 0 15px 0;
.item.row {
display: flex;
background: #f4f0ec;
border: #cfcfc4 1px solid;
border-radius: 10px;
padding: 15px;
flex-wrap: wrap;
.header {
.item.id {
border: 1px #cfcfc4 solid;
border-radius: 10px;
padding: 5px;
margin-right: 5px;
background: #FFF;
}
}
.footer {
.react-mount.issues {
ul {
margin: 0;
padding: 0;
list-style-type: none;
li {
margin: 0 0 15px 0;
.item.row {
display: flex;
width: 100%;
justify-content: space-between;
margin: 20px 0 0 0;
font-size: small;
background: #f4f0ec;
border: #cfcfc4 1px solid;
border-radius: 10px;
padding: 10px;
flex-wrap: wrap;
.header {
.item.id {
border: 1px #cfcfc4 solid;
border-radius: 10px;
padding: 5px;
margin-right: 5px;
background: #FFF;
}
}
.footer {
display: flex;
width: 100%;
justify-content: space-between;
margin: 10px 0 0 0;
font-size: small;
}
}
}
}

View file

@ -1,5 +1,4 @@
<div class="root">
<div class="react-mount navbar"></div>
<div class="react-mount issues"></div>
<script src="/js/entry-point/issues.js"></script>
</div>

View file

@ -1,4 +1,4 @@
<div class="root">
<div className="react-mount-point" id="reactapp"></div>
<div class="react-mount new-issue"></div>
<script src="/js/entry-point/new-issue.js"></script>
</div>

View file

@ -1,4 +1,4 @@
<div class="root">
<div className="react-mount-point" id="reactapp"></div>
<div class="react-mount edit-issue"></div>
<script src="/js/entry-point/read-issue.js"></script>
</div>

View file

@ -0,0 +1,95 @@
import React, { useEffect, useState, useRef } from "react";
import { useForm } from "react-hook-form";
import { Select } from "/components/forms/Select";
import { useUpsertIssue } from "/hooks/useUpsertIssue";
import { useConnections } from "/hooks/useConnections";
import { Issue } from "/types/schema";
import showdown from "showdown";
type Inputs = {
id?: number;
title: string;
content: string;
connectionId: number;
};
export function Issue({ issue }: { issue?: Issue }) {
const { register, handleSubmit, watch, setValue: set } = useForm<Inputs>();
const [isEditable, setIsEditable] = useState<boolean>(!issue);
const selectRef = useRef<HTMLSelectElement>(null);
const upsert = useUpsertIssue();
const [connections] = useConnections();
const c = new showdown.Converter();
const content = watch("content");
const onSave = (input: Inputs) => {
upsert({ input }).then(() => {
location.href = "/issues/";
});
};
useEffect(() => {
set("connectionId", 1);
}, []);
return (
<form className="issue" onSubmit={handleSubmit(onSave)}>
<input type="hidden" value={issue?.id} {...register("id")} />
<div className="table">
<div className="table tabbed div">
<ul className="tabs">
<li onClick={() => setIsEditable(true)}>Write</li>
<li onClick={() => setIsEditable(false)}>Preview</li>
</ul>
</div>
<div className="table content">
<div>
<Select
{...register("connectionId")}
ref={selectRef}
className="form"
>
{connections.map((conn, key) => {
return (
<option key={key} value={conn.id}>
{conn.name}
</option>
);
})}
</Select>
</div>
<div>
<input
className="form"
type="text"
placeholder="Title"
defaultValue={issue?.title}
{...register("title", { required: true })}
/>
</div>
{isEditable ? (
<>
<div className="row textarea">
<textarea
className="form"
placeholder="Add your description heren"
defaultValue={issue?.content}
{...register("content", { required: true })}
/>
</div>
<div className="row">
<input className="form" type="submit" value="Save" />
</div>
</>
) : (
<div
className="issue content"
dangerouslySetInnerHTML={{
__html: c.makeHtml(content || issue?.content),
}}
/>
)}
</div>
</div>
</form>
);
}

View file

@ -10,34 +10,39 @@ export function Issues() {
setIssues(issues.filter(i => i.id !== issue.id));
};
return (
<ul>
{issues.map((issue: Issue, key: number) => {
const { updated_at: updatedAt } = issue;
const datetime = DateTime.fromISO(updatedAt);
return (
<li key={key}>
<div className="item row">
<div className="header">
<a href={`/issues/read#id=${issue.id}`}>
<span className="item title">{issue.title}</span>
</a>
</div>
<div className="footer">
<div>
<DestroyIssueButton
issue={issue}
onSuccess={onDestroySuccess}
/>
<div className="table">
<div className="table div">Issues</div>
<div className="table content">
<ul>
{issues.map((issue: Issue, key: number) => {
const { updated_at: updatedAt } = issue;
const datetime = DateTime.fromISO(updatedAt);
return (
<li key={key}>
<div className="item row">
<div className="header">
<a href={`/issues/read#id=${issue.id}`}>
<span className="item title">{issue.title}</span>
</a>
</div>
<div className="footer">
<div>
<DestroyIssueButton
issue={issue}
onSuccess={onDestroySuccess}
/>
</div>
<span>
{datetime.toFormat("dd LLL, yyyy")} at{" "}
{datetime.toFormat("HH:mm")}
</span>
</div>
</div>
<span>
{datetime.toFormat("dd LLL, yyyy")} at{" "}
{datetime.toFormat("HH:mm")}
</span>
</div>
</div>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</div>
</div>
);
}

View file

@ -2,7 +2,6 @@ import React from "react";
export function NavBar() {
return (
<div className="navbar">
<div className="h1">Issues</div>
<div>
<a href="/issues/new">New issue</a>
</div>

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { Issue } from "/types/schema";
import { useParams } from "/hooks/useParams";
import { Issue } from "/types/schema";
import { Issue as Component } from "/components/Issue";
export function ReadIssue() {
const { id } = useParams();
@ -14,12 +15,7 @@ export function ReadIssue() {
}, [id]);
if (id?.length && issue) {
return (
<div className="pure-u-5-5">
<div className="pure-u-1-1">{issue.title}</div>
<p className="pure-u-3-5">{issue.content}</p>
</div>
);
return <Component issue={issue} />;
} else {
return null;
}

View file

@ -1,61 +0,0 @@
import React, { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { Select } from "/components/forms/Select";
import { useCreateIssue } from "/hooks/useCreateIssue";
import { useConnections } from "/hooks/useConnections";
type Inputs = {
title: string;
content: string;
connectionId: number;
};
export function NewIssue() {
const { register, handleSubmit, setValue: set } = useForm<Inputs>();
const selectRef = useRef<HTMLSelectElement>(null);
const [create] = useCreateIssue();
const [connections] = useConnections();
const onSave = (input: Inputs) => {
create({ input }).then(() => {
location.href = "/issues/";
});
};
useEffect(() => {
set("connectionId", 1);
}, []);
return (
<form onSubmit={handleSubmit(onSave)}>
<div className="row">
<Select {...register("connectionId")} ref={selectRef} className="form">
{connections.map((conn, key) => {
return (
<option key={key} value={conn.id}>
{conn.name}
</option>
);
})}
</Select>
</div>
<div className="row">
<input
className="form"
type="text"
placeholder="Title"
{...register("title", { required: true })}
/>
</div>
<div className="row textarea">
<textarea
className="form"
placeholder="Add your description here"
{...register("content", { required: true })}
/>
</div>
<div className="row">
<input className="form" type="submit" value="Save" />
</div>
</form>
);
}

View file

@ -1,11 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Issues } from "/components/Issues";
import { NavBar } from "/components/NavBar";
(function () {
const n1 = document.querySelector(".react-mount.navbar");
const n2 = document.querySelector(".react-mount.issues")!;
ReactDOM.createRoot(n1).render(<NavBar />);
ReactDOM.createRoot(n2).render(<Issues />);
const n1 = document.querySelector(".react-mount.issues")!;
ReactDOM.createRoot(n1).render(<Issues />);
})();

View file

@ -1,8 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { NewIssue } from "/components/forms/NewIssue";
import { Issue } from "/components/Issue";
(function () {
const root = document.getElementById("reactapp")!;
ReactDOM.createRoot(root).render(<NewIssue />);
const root = document.querySelector(".react-mount.new-issue")!;
ReactDOM.createRoot(root).render(<Issue />);
})();

View file

@ -3,6 +3,6 @@ import ReactDOM from "react-dom/client";
import { ReadIssue } from "/components/ReadIssue";
(function () {
const root = document.getElementById("reactapp")!;
const root = document.querySelector(".react-mount.edit-issue")!;
ReactDOM.createRoot(root).render(<ReadIssue />);
})();

View file

@ -1,23 +0,0 @@
type Params = {
title: string;
content: string;
connectionId: number;
};
export function useCreateIssue() {
return [
function ({ input }: { input: Params }) {
return new Promise((accept, reject) => {
const { connectionId, title, content } = input;
const req = {
method: "POST",
body: JSON.stringify({ connection_id: connectionId, title, content }),
};
return fetch("/servlet/issues", req)
.then(res => res.json())
.then(accept)
.catch(reject);
});
},
];
}

View file

@ -23,5 +23,5 @@ export function useIssues(): Result {
req();
}, []);
return { issues, setIssues, req };
return { issues, setIssues: set, req };
}

View file

@ -0,0 +1,25 @@
type Params = {
id?: number;
title: string;
content: string;
connectionId: number;
};
export function useUpsertIssue() {
const normalize = (input: Params) => {
const { id, title, content, connectionId } = input;
return { id, title, content, connection_id: connectionId };
};
return function ({ input }: { input: Params }) {
return new Promise((accept, reject) => {
const req = {
method: input.id ? "PUT" : "POST",
body: JSON.stringify(normalize(input)),
};
return fetch("/servlet/issues", req)
.then(res => res.json())
.then(accept)
.catch(reject);
});
};
}