mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-12-24 17:44:21 +03:00
Implemented first version of 'thoughts' example app.
This commit is contained in:
parent
79bb1c7a82
commit
62d5049f7e
1
examples/thoughts/.env
Normal file
1
examples/thoughts/.env
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL=postgresql://postgres:devpass@localhost:5432/postgres
|
1
examples/thoughts/.gitignore
vendored
Normal file
1
examples/thoughts/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/.wasp/
|
1
examples/thoughts/.wasproot
Normal file
1
examples/thoughts/.wasproot
Normal file
@ -0,0 +1 @@
|
||||
File marking the root of Wasp project.
|
29
examples/thoughts/README.md
Normal file
29
examples/thoughts/README.md
Normal file
@ -0,0 +1,29 @@
|
||||
Thoughts
|
||||
==========
|
||||
|
||||
**Thoughts** is a note-taking app organized around the concept of hashtags.
|
||||
|
||||
Run `wasp start` to start the app in development mode.
|
||||
|
||||
## TODO
|
||||
|
||||
## How it felt so far to build this app in Wasp
|
||||
|
||||
Here I write down how I felt while developing this app, so we can use this feedback in the future to improve Wasp. Subjective feedback is also written down.
|
||||
|
||||
- CSS is hard. Writing CSS globally is not fun. I would like to write it somehow better (inline)? I would also like to use a pre-processor (sass, stylus).
|
||||
- I was modifying the entity and couldn't perform migration because it would destroy data. So I emptied the database. I did that by using `wasp clean`, but I am not sure if others would know they can do it this way, plus this method works only for SQLite.
|
||||
- I had an error coming from an action -> it was not super clear from the error message where it came from.
|
||||
- I have to remember to do migrate-dev.
|
||||
- I have to remember to restart Wasp when I add dependency.
|
||||
- Could we have immutable data in DB? Is that posible? This is just thought, we probably shouldn't think about this.
|
||||
- UI is hard.
|
||||
- Operations code and declaration should be closer and less boilerplatish.
|
||||
- If calling now non-existent operation that existed previously, 404 is returned -> very hard to figure out what the error is from this! This happens because old generated operation file remains in the generated FE code. If it was removed, error message would be better.
|
||||
- Wasp declares action where import stmt leads to nowhere -> hard to debug the error!
|
||||
- I added creation of tags (via Prisma's `connect` mechanism) to action that had `Thought` under `entities` and forgot to add `Tag` under `entities` in action declaration in .wasp code! Is there a way to force this?
|
||||
- `wasp db migrate-dev` after `wasp clean` takes long time with no output.
|
||||
- Figuring out what should be a new page and what should not -> hard. Details: I wasn't sure if I should do multiple pages that share some components (sidebar, navbar), or if I should have just one page and then use in-page router for deciding what to show in the center of the page. This dillema would be present in pure React app also.
|
||||
- Handling errors in React and fetching data -> boring, also not sure how to do it.
|
||||
- I forgot to run `wasp db migrate-dev` after I switched db.system to PostgreSQL, and it took me some time to figure out why it is still using SQLite.
|
||||
- I got a message from Prisma that I should remove my migrations directory. It can be confusing for the newcomers as to what really needs to be done.
|
3
examples/thoughts/ext/.waspignore
Normal file
3
examples/thoughts/ext/.waspignore
Normal file
@ -0,0 +1,3 @@
|
||||
# Ignore editor tmp files
|
||||
**/*~
|
||||
**/#*#
|
18
examples/thoughts/ext/LoginPage.js
Normal file
18
examples/thoughts/ext/LoginPage.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import LoginForm from '@wasp/auth/forms/Login'
|
||||
|
||||
const LoginPage = (props) => {
|
||||
return (
|
||||
<>
|
||||
<LoginForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I don't have an account yet (<Link to="/signup">go to signup</Link>).
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
31
examples/thoughts/ext/Main.css
Normal file
31
examples/thoughts/ext/Main.css
Normal file
@ -0,0 +1,31 @@
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
button.plain {
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
button.plain:hover {
|
||||
cursor: pointer;
|
||||
}
|
181
examples/thoughts/ext/MainPage.js
Normal file
181
examples/thoughts/ext/MainPage.js
Normal file
@ -0,0 +1,181 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import './Main.css'
|
||||
import './Thought.css'
|
||||
import TagsSidebar from './TagsSidebar.js'
|
||||
import TopNavbar from './TopNavbar.js'
|
||||
import { getTagColor } from './tag.js'
|
||||
|
||||
import createThought from '@wasp/actions/createThought'
|
||||
import { useQuery } from '@wasp/queries'
|
||||
|
||||
// TODO:
|
||||
// - Rename this file to Thought.js.
|
||||
// - Allow editing of existing thought.
|
||||
// - Allow deleting thoughts.
|
||||
// - Allow renaming tags.
|
||||
// - Implement pagination.
|
||||
// - When listing thoughts, show only first couple of lines (or just first line).
|
||||
// - Sort tags by the number of thoughts under them, descending?
|
||||
// - Implement searching/filtering through tags.
|
||||
// - Implement searching through the thoughts text.
|
||||
// - Allow hierarhical tags -> tags with '.' in them are "magical".
|
||||
// So, if Thought has #haskell and #haskell.exceptions tags, only #haskell tag
|
||||
// will be visible on the left side, while #haskell.exceptions tag will be shown as an "exceptions" tag
|
||||
// under the #haskell tag. Maybe there are some other smart ways of using this property.
|
||||
// - Set favicon.
|
||||
// - Support sharing thoughts (making them public). Not sure how this would go.
|
||||
// - Refactor and improve code.
|
||||
|
||||
const MainPage = ({ user }) => {
|
||||
// TODO: Remove duplication! layout, navbar, sidebar, ...
|
||||
return (
|
||||
<div className="main-page">
|
||||
<TopNavbar user={user} />
|
||||
|
||||
<div className="main-container">
|
||||
<TagsSidebar />
|
||||
<Thought />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Thought = (props) => {
|
||||
const defaultTextMd = ''
|
||||
const defaultNewTagName = ''
|
||||
const defaultTagNames = []
|
||||
const defaultInPreviewMode = false
|
||||
const [textMd, setTextMd] = useState(defaultTextMd)
|
||||
const [tagNames, setTagNames] = useState(defaultTagNames)
|
||||
const [newTagName, setNewTagName] = useState(defaultNewTagName)
|
||||
const [inPreviewMode, setInPreviewMode] = useState(defaultInPreviewMode)
|
||||
const history = useHistory()
|
||||
const formRef = useRef(null) // TODO: Why do I have this ref? I don't seem to use it anywhere?
|
||||
|
||||
const resetForm = () => {
|
||||
setTextMd(defaultTextMd)
|
||||
setTagNames(defaultTagNames)
|
||||
setNewTagName(defaultNewTagName)
|
||||
setInPreviewMode(defaultInPreviewMode)
|
||||
}
|
||||
|
||||
const togglePreviewMode = () => {
|
||||
setInPreviewMode(!inPreviewMode)
|
||||
}
|
||||
|
||||
const setNewTagNameIfValid = (tagName) => {
|
||||
if (!tagName || /^[a-z](\.?[a-z0-9])*\.?$/.test(tagName)) {
|
||||
setNewTagName(tagName)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault()
|
||||
if (textMd.trim()) {
|
||||
if (!tagNames?.length) {
|
||||
return window.alert('You need to define at least one tag!')
|
||||
}
|
||||
try {
|
||||
await createThought({ textMarkdown: textMd.trim(), tagNames })
|
||||
history.push('/thoughts') // TODO: Would be cool if this was type checked somehow or if string was coming from the Wasp API.
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
return window.alert('Error: ' + err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleInPreviewMode = () => {
|
||||
setInPreviewMode(!inPreviewMode)
|
||||
}
|
||||
|
||||
const handleThoughtEditorKeyPress = async (e) => {
|
||||
if (e.key === 'Enter' && e.altKey) {
|
||||
await submit(e)
|
||||
}
|
||||
}
|
||||
|
||||
const addNewTag = () => {
|
||||
if (!newTagName) return
|
||||
console.log(newTagName)
|
||||
const tagNameToAdd = newTagName.replace(/\.$/, '')
|
||||
if (!tagNames.includes(tagNameToAdd)) {
|
||||
setTagNames([...tagNames, tagNameToAdd])
|
||||
setNewTagName('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tagName) => {
|
||||
setTagNames(tagNames.filter(name => name !== tagName))
|
||||
}
|
||||
|
||||
const handleTagsKeyPress = async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addNewTag()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagsBlur = async (e) => {
|
||||
addNewTag()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thought">
|
||||
<form ref={formRef}>
|
||||
|
||||
<div className="thought-tags">
|
||||
{ tagNames.map(tagName => (
|
||||
<div className="thought-tags-tag"
|
||||
onClick={() => removeTag(tagName)}
|
||||
key={tagName}
|
||||
style={{ color: getTagColor(tagName) }}>
|
||||
{ tagName }
|
||||
</div>
|
||||
))}
|
||||
<span className="thought-tags-new">
|
||||
#
|
||||
<input
|
||||
type="text" value={newTagName} onChange={e => setNewTagNameIfValid(e.target.value)}
|
||||
onKeyDown={handleTagsKeyPress}
|
||||
onBlur={handleTagsBlur}
|
||||
placeholder="add.tags.here..."
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="thought-text">
|
||||
{ inPreviewMode
|
||||
? <div className="thought-preview">
|
||||
<ReactMarkdown children={textMd} />
|
||||
</div>
|
||||
: <div className="thought-editor">
|
||||
<textarea
|
||||
value={textMd}
|
||||
onChange={e => setTextMd(e.target.value)}
|
||||
onKeyDown={handleThoughtEditorKeyPress}
|
||||
placeholder="Write here (Markdown supported, Alt+Enter to submit) ..."
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="thought-buttons">
|
||||
<button className="plain"
|
||||
onClick={e => { e.preventDefault(); toggleInPreviewMode() }}>
|
||||
{ inPreviewMode ? 'edit' : 'preview' }
|
||||
</button>
|
||||
|
|
||||
<button className="plain" onClick={submit}> submit </button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MainPage
|
18
examples/thoughts/ext/SignupPage.js
Normal file
18
examples/thoughts/ext/SignupPage.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import SignupForm from '@wasp/auth/forms/Signup'
|
||||
|
||||
const SignupPage = (props) => {
|
||||
return (
|
||||
<>
|
||||
<SignupForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I already have an account (<Link to="/login">go to login</Link>).
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignupPage
|
36
examples/thoughts/ext/TagsSidebar.css
Normal file
36
examples/thoughts/ext/TagsSidebar.css
Normal file
@ -0,0 +1,36 @@
|
||||
.tags-sidebar {
|
||||
height: 100%;
|
||||
/* NOTE: I added min-width because width was not respected, it was narrower than 300px.
|
||||
I don't understand why! When I use min-width, actual width is 300px. */
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.tags-sidebar-tag {
|
||||
padding: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.tags-sidebar-tag:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
.tags-sidebar-tag.active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.new-thought-link {
|
||||
color: grey;
|
||||
text-decoration: none;
|
||||
border: 1px grey dashed;
|
||||
font-size: 20px;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
}
|
||||
.new-thought-link:hover {
|
||||
font-weight: 400;
|
||||
border: 1px grey solid;
|
||||
}
|
35
examples/thoughts/ext/TagsSidebar.js
Normal file
35
examples/thoughts/ext/TagsSidebar.js
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import './TagsSidebar.css'
|
||||
import { getTagColor } from './tag.js'
|
||||
|
||||
import getTags from '@wasp/queries/getTags'
|
||||
import { useQuery } from '@wasp/queries'
|
||||
|
||||
// Props: active TODO: document this properly.
|
||||
const TagsSidebar = (props) => {
|
||||
const { data: tags, isFetching, error } = useQuery(getTags)
|
||||
|
||||
return (
|
||||
<div className="tags-sidebar">
|
||||
<Link to="/" className="new-thought-link"> New thought </Link>
|
||||
<Link to={`/thoughts`}
|
||||
className={`tags-sidebar-tag tags-sidebar-tag-all ${props.active === '_all' ? 'active' : ''}`}
|
||||
style={{ color: 'black' }}
|
||||
key="_all">
|
||||
all
|
||||
</Link>
|
||||
{ tags && tags.map(tag => (
|
||||
<Link to={`/thoughts?tag=${tag.name}`}
|
||||
className={`tags-sidebar-tag ${props.active === tag.name ? 'active' : ''}`}
|
||||
style={{ color: getTagColor(tag.name)}}
|
||||
key={tag.name}>
|
||||
#{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagsSidebar
|
79
examples/thoughts/ext/Thought.css
Normal file
79
examples/thoughts/ext/Thought.css
Normal file
@ -0,0 +1,79 @@
|
||||
.thought {
|
||||
min-height: 90vh;
|
||||
margin-right: 300px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thought form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 50px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.thought-preview {
|
||||
width: 800px;
|
||||
height: 66vh;
|
||||
font-size: 20px;
|
||||
border: 0px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
resize: none;
|
||||
margin-bottom: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.thought-editor textarea {
|
||||
width: 800px;
|
||||
height: 66vh;
|
||||
font-size: 20px;
|
||||
border: 0px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
resize: none;
|
||||
margin-bottom: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.thought-editor textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.thought-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-flow: flex-start;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.thought-tags-tag {
|
||||
margin: 0px 10px;
|
||||
}
|
||||
.thought-tags-tag:hover{
|
||||
text-decoration: line-through;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thought-tags-tag::before {
|
||||
content: "#";
|
||||
}
|
||||
|
||||
.thought-tags-new {
|
||||
color: grey;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.thought-tags-new input {
|
||||
border: 0px;
|
||||
font: inherit;
|
||||
}
|
||||
.thought-tags-new input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.thought-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
55
examples/thoughts/ext/ThoughtsPage.css
Normal file
55
examples/thoughts/ext/ThoughtsPage.css
Normal file
@ -0,0 +1,55 @@
|
||||
.center-container {
|
||||
min-height: 90vh;
|
||||
margin-right: 300px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thoughts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 50px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thought-list-view {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.thought-list-view-text {
|
||||
font-size: 16px;
|
||||
width: 800px;
|
||||
/* TODO: uncomment line below to see only the top of the text,
|
||||
not the whole text. If we are going with this, we need a way to show
|
||||
the full thought. */
|
||||
/* max-height: 5em; */
|
||||
overflow: hidden;
|
||||
border: 0px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
margin: 10px;
|
||||
padding: 10px 20px;
|
||||
position: relative;
|
||||
}
|
||||
/* This makes the text at the end of the box "fade out" */
|
||||
.thought-list-view-text:after {
|
||||
content: "";
|
||||
position: absolute; top: 0; bottom: 0; left: -15px; right: -15px;
|
||||
box-shadow: 0 -15px 25px 5px white inset;
|
||||
}
|
||||
|
||||
.thought-list-view-tags {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-flow: flex-start;
|
||||
}
|
||||
.thought-list-view-tags-tag {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.thought-list-view-tags-tag::before {
|
||||
content: "#";
|
||||
}
|
67
examples/thoughts/ext/ThoughtsPage.js
Normal file
67
examples/thoughts/ext/ThoughtsPage.js
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
import './Main.css'
|
||||
import './ThoughtsPage.css'
|
||||
import { getTagColor } from './tag.js'
|
||||
import TagsSidebar from './TagsSidebar.js'
|
||||
import TopNavbar from './TopNavbar.js'
|
||||
|
||||
import getThoughts from '@wasp/queries/getThoughts'
|
||||
import { useQuery } from '@wasp/queries'
|
||||
|
||||
|
||||
const ThoughtsPage = (props) => {
|
||||
const queryParams = new URLSearchParams(useLocation().search)
|
||||
const tag = queryParams.get('tag')
|
||||
|
||||
// TODO: Handle possible errors and fetching.
|
||||
const { data: thoughts, isFetching, error } = useQuery(getThoughts, { tagName: tag })
|
||||
|
||||
// TODO: Duplication! layout, navbar, sidebar, ...
|
||||
return (
|
||||
<div className="main-page">
|
||||
<TopNavbar user={props.user} />
|
||||
|
||||
<div className="main-container">
|
||||
<TagsSidebar active={tag || '_all'} />
|
||||
|
||||
<div className="center-container">
|
||||
<ThoughtsList thoughts={thoughts} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ThoughtsList = ({ thoughts }) => {
|
||||
return (
|
||||
<div className="thoughts-list">
|
||||
{ thoughts?.length ? thoughts.map((thought, idx) =>
|
||||
<ThoughtListView thought={thought} key={thought.id} />
|
||||
) : 'No thoughts to show'
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ThoughtListView = (props) => (
|
||||
<div className="thought-list-view">
|
||||
<div className="thought-list-view-tags">
|
||||
{props.thought.tags?.map(tag => (
|
||||
<div className="thought-list-view-tags-tag"
|
||||
style={{ color: getTagColor(tag.name)}}
|
||||
key={tag.name}>
|
||||
{tag.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="thought-list-view-text">
|
||||
<ReactMarkdown children={props.thought.textMarkdown} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ThoughtsPage
|
8
examples/thoughts/ext/TopNavbar.css
Normal file
8
examples/thoughts/ext/TopNavbar.css
Normal file
@ -0,0 +1,8 @@
|
||||
.top-navbar {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: row;
|
||||
padding: 10px;
|
||||
}
|
20
examples/thoughts/ext/TopNavbar.js
Normal file
20
examples/thoughts/ext/TopNavbar.js
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
|
||||
import logout from '@wasp/auth/logout.js'
|
||||
|
||||
import './TopNavbar.css'
|
||||
|
||||
|
||||
const TopNavbar = (props) => {
|
||||
const user = props.user
|
||||
|
||||
return (
|
||||
<div className="top-navbar">
|
||||
{ user.email }
|
||||
|
|
||||
<button className="plain" onClick={logout}> logout </button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopNavbar
|
20
examples/thoughts/ext/actions.js
Normal file
20
examples/thoughts/ext/actions.js
Normal file
@ -0,0 +1,20 @@
|
||||
import HttpError from '@wasp/core/HttpError.js'
|
||||
|
||||
export const createThought = async (args, context) => {
|
||||
args.tagNames?.map(tagName => {
|
||||
if (!/^[a-z](\.?[a-z0-9])*$/.test(tagName)) {
|
||||
throw new HttpError(400, "Tag must contain only lowercase letters and dots.")
|
||||
}
|
||||
})
|
||||
return context.entities.Thought.create({
|
||||
data: {
|
||||
textMarkdown: args.textMarkdown,
|
||||
tags: {
|
||||
connectOrCreate: args.tagNames?.map(tagName => ({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName }
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
15
examples/thoughts/ext/queries.js
Normal file
15
examples/thoughts/ext/queries.js
Normal file
@ -0,0 +1,15 @@
|
||||
export const getThoughts = async (args, context) => {
|
||||
return context.entities.Thought.findMany({
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: { tags: true },
|
||||
where: {
|
||||
tags: { some: { name: args.tagName || undefined } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getTags = async (args, context) => {
|
||||
return context.entities.Tag.findMany({
|
||||
orderBy: [{ name: 'asc' }],
|
||||
})
|
||||
}
|
6
examples/thoughts/ext/tag.js
Normal file
6
examples/thoughts/ext/tag.js
Normal file
@ -0,0 +1,6 @@
|
||||
import ColorHash from 'color-hash'
|
||||
|
||||
export const getTagColor = (tagName) => {
|
||||
const colorHash = new ColorHash()
|
||||
return colorHash.hex(tagName)
|
||||
}
|
BIN
examples/thoughts/ext/waspLogo.png
Normal file
BIN
examples/thoughts/ext/waspLogo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
76
examples/thoughts/main.wasp
Normal file
76
examples/thoughts/main.wasp
Normal file
@ -0,0 +1,76 @@
|
||||
app Thoughts {
|
||||
title: "Thoughts"
|
||||
}
|
||||
|
||||
db {
|
||||
system: PostgreSQL
|
||||
}
|
||||
|
||||
auth {
|
||||
userEntity: User,
|
||||
methods: [ EmailAndPassword ],
|
||||
onAuthFailedRedirectTo: "/login"
|
||||
}
|
||||
|
||||
route "/" -> page Main
|
||||
page Main {
|
||||
component: import Main from "@ext/MainPage.js",
|
||||
authRequired: true
|
||||
}
|
||||
|
||||
route "/thoughts" -> page Thoughts
|
||||
page Thoughts {
|
||||
component: import Thoughts from "@ext/ThoughtsPage.js",
|
||||
authRequired: true
|
||||
}
|
||||
|
||||
route "/login" -> page Login
|
||||
page Login {
|
||||
component: import Login from "@ext/LoginPage.js"
|
||||
}
|
||||
|
||||
route "/signup" -> page Signup
|
||||
page Signup {
|
||||
component: import Signup from "@ext/SignupPage"
|
||||
}
|
||||
|
||||
action createThought {
|
||||
fn: import { createThought } from "@ext/actions.js",
|
||||
entities: [Thought, Tag]
|
||||
}
|
||||
|
||||
query getThoughts {
|
||||
fn: import { getThoughts } from "@ext/queries.js",
|
||||
entities: [Thought]
|
||||
}
|
||||
|
||||
query getTags {
|
||||
fn: import { getTags } from "@ext/queries.js",
|
||||
entities: [Tag]
|
||||
}
|
||||
|
||||
entity Thought {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
textMarkdown String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tags Tag[]
|
||||
psl=}
|
||||
|
||||
entity Tag {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
thoughts Thought[]
|
||||
psl=}
|
||||
|
||||
entity User {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
psl=}
|
||||
|
||||
dependencies {=json
|
||||
"react-markdown": "6.0.1",
|
||||
"color-hash": "2.0.1"
|
||||
json=}
|
51
examples/thoughts/migrations/20210513205603_/migration.sql
Normal file
51
examples/thoughts/migrations/20210513205603_/migration.sql
Normal file
@ -0,0 +1,51 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Thought" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"textMarkdown" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_TagToThought" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_TagToThought_AB_unique" ON "_TagToThought"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_TagToThought_B_index" ON "_TagToThought"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TagToThought" ADD FOREIGN KEY ("A") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TagToThought" ADD FOREIGN KEY ("B") REFERENCES "Thought"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
3
examples/thoughts/migrations/migration_lock.toml
Normal file
3
examples/thoughts/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
Loading…
Reference in New Issue
Block a user