mirror of
https://github.com/vlang/v.git
synced 2025-09-09 15:27:05 -04:00
examples: add TODO app example with x.vweb (#20175)
This commit is contained in:
parent
22cb9c55df
commit
70c575a9f2
1
examples/xvweb/todo/.gitignore
vendored
Normal file
1
examples/xvweb/todo/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
*.db
|
19
examples/xvweb/todo/README.md
Normal file
19
examples/xvweb/todo/README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# A simple TODO app using x.vweb
|
||||||
|
|
||||||
|
A simple TODO app using `x.vweb` showcasing how to build a basic web app with vweb.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
This example uses an sqlite database using the `db.sqlite` package,
|
||||||
|
but you can use any database from the `db` module.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Run from this directory with
|
||||||
|
```bash
|
||||||
|
v run main.v
|
||||||
|
```
|
||||||
|
You can also enable vweb's livereload feature with
|
||||||
|
```bash
|
||||||
|
v watch -d vweb_livereload run main.v`
|
||||||
|
```
|
122
examples/xvweb/todo/assets/main.css
Normal file
122
examples/xvweb/todo/assets/main.css
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 50px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* simple styles reset */
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
outline: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
appearance: none;
|
||||||
|
outline: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
border: 1px solid #d3d3d3;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background-color: #3b71ca;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
button.error {
|
||||||
|
background-color: red;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
button.success {
|
||||||
|
background-color: green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-success {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
section.create-todo {
|
||||||
|
max-width: fit-content;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid #d3d3d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo .name {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-id, .time {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #3d3d3d;
|
||||||
|
margin: 0px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* we're mobile friendly */
|
||||||
|
@media only screen and (max-width: 900px) {
|
||||||
|
body {
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.todo p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.create-todo form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
145
examples/xvweb/todo/main.v
Normal file
145
examples/xvweb/todo/main.v
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
// Simple TODO app using x.vweb
|
||||||
|
// Run from this directory with `v run main.v`
|
||||||
|
// You can also enable vwebs livereload feature with
|
||||||
|
// `v watch -d vweb_livereload run main.v`
|
||||||
|
module main
|
||||||
|
|
||||||
|
import x.vweb
|
||||||
|
import db.sqlite
|
||||||
|
import time
|
||||||
|
|
||||||
|
struct Todo {
|
||||||
|
pub mut:
|
||||||
|
// `id` is the primary field. The attribute `sql: serial` acts like AUTO INCREMENT in sql.
|
||||||
|
// You can use this attribute if you want a unique id for each row.
|
||||||
|
id int @[primary; sql: serial]
|
||||||
|
name string
|
||||||
|
completed bool
|
||||||
|
created time.Time
|
||||||
|
updated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Context {
|
||||||
|
vweb.Context
|
||||||
|
pub mut:
|
||||||
|
// we can use this field to check whether we just created a TODO in our html templates
|
||||||
|
created_todo bool
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
vweb.StaticHandler
|
||||||
|
pub:
|
||||||
|
// we can access the SQLITE database directly via `app.db`
|
||||||
|
db sqlite.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method will only handle GET requests to the index page
|
||||||
|
@[get]
|
||||||
|
pub fn (app &App) index(mut ctx Context) vweb.Result {
|
||||||
|
todos := sql app.db {
|
||||||
|
select from Todo
|
||||||
|
} or { return ctx.server_error('could not fetch todos from database!') }
|
||||||
|
|
||||||
|
// TODO: use $vweb.html()
|
||||||
|
return ctx.html($tmpl('templates/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method will only handle POST requests to the index page
|
||||||
|
@['/'; post]
|
||||||
|
pub fn (app &App) create_todo(mut ctx Context, name string) vweb.Result {
|
||||||
|
// We can receive form input fields as arguments in a route!
|
||||||
|
// we could also access the name field by doing `name := ctx.form['name']`
|
||||||
|
|
||||||
|
// validate input field
|
||||||
|
if name.len == 0 {
|
||||||
|
// set a form error
|
||||||
|
ctx.form_error = 'You must fill in all the fields!'
|
||||||
|
// send a HTTP 400 response code indicating that the form fields are incorrect
|
||||||
|
ctx.res.set_status(.bad_request)
|
||||||
|
// render the home page
|
||||||
|
return app.index(mut ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a new todo
|
||||||
|
todo := Todo{
|
||||||
|
name: name
|
||||||
|
created: time.now()
|
||||||
|
updated: time.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert the todo into our database
|
||||||
|
sql app.db {
|
||||||
|
insert todo into Todo
|
||||||
|
} or { return ctx.server_error('could not insert a new TODO in the datbase') }
|
||||||
|
|
||||||
|
ctx.created_todo = true
|
||||||
|
|
||||||
|
// render the home page
|
||||||
|
return app.index(mut ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@['/todo/:id/complete'; post]
|
||||||
|
pub fn (app &App) complete_todo(mut ctx Context, id int) vweb.Result {
|
||||||
|
// first check if there exist a TODO record with `id`
|
||||||
|
todos := sql app.db {
|
||||||
|
select from Todo where id == id
|
||||||
|
} or { return ctx.server_error("could not fetch TODO's") }
|
||||||
|
if todos.len == 0 {
|
||||||
|
// return HTTP 404 when the TODO does not exist
|
||||||
|
ctx.res.set_status(.not_found)
|
||||||
|
return ctx.text('There is no TODO item with id=${id}')
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the TODO field
|
||||||
|
sql app.db {
|
||||||
|
update Todo set completed = true, updated = time.now() where id == id
|
||||||
|
} or { return ctx.server_error('could not update TODO') }
|
||||||
|
|
||||||
|
// redirect client to the home page and tell the browser to sent a GET request
|
||||||
|
return ctx.redirect('/', .see_other)
|
||||||
|
}
|
||||||
|
|
||||||
|
@['/todo/:id/delete'; post]
|
||||||
|
pub fn (app &App) delete_todo(mut ctx Context, id int) vweb.Result {
|
||||||
|
// first check if there exist a TODO record with `id`
|
||||||
|
todos := sql app.db {
|
||||||
|
select from Todo where id == id
|
||||||
|
} or { return ctx.server_error("could not fetch TODO's") }
|
||||||
|
if todos.len == 0 {
|
||||||
|
// return HTTP 404 when the TODO does not exist
|
||||||
|
ctx.res.set_status(.not_found)
|
||||||
|
return ctx.text('There is no TODO item with id=${id}')
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent hackers from deleting TODO's that are not completed ;)
|
||||||
|
to_be_deleted := todos[0]
|
||||||
|
if to_be_deleted.completed == false {
|
||||||
|
return ctx.request_error('You must first complete a TODO before you can delete it!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the todo
|
||||||
|
sql app.db {
|
||||||
|
delete from Todo where id == id
|
||||||
|
} or { return ctx.server_error('could not delete TODO') }
|
||||||
|
|
||||||
|
// redirect client to the home page and tell the browser to sent a GET request
|
||||||
|
return ctx.redirect('/', .see_other)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// create a new App instance with a connection to the datbase
|
||||||
|
mut app := &App{
|
||||||
|
db: sqlite.connect('todo.db')!
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount the assets folder at `/assets/`
|
||||||
|
app.handle_static('assets', false)!
|
||||||
|
|
||||||
|
// create the table in our database, if it doesn't exist
|
||||||
|
sql app.db {
|
||||||
|
create table Todo
|
||||||
|
}!
|
||||||
|
|
||||||
|
// start our app at port 8080
|
||||||
|
vweb.run[App, Context](mut app, 8080)
|
||||||
|
}
|
64
examples/xvweb/todo/templates/index.html
Normal file
64
examples/xvweb/todo/templates/index.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>My TODO App</title>
|
||||||
|
<!-- include our css from the assets folder -->
|
||||||
|
@css '/assets/main.css'
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>List of all my todos</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Display a message when a new TODO is created -->
|
||||||
|
@if ctx.created_todo
|
||||||
|
<p class="form-success">Created a new todo!</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<section class="todos">
|
||||||
|
<div class="todo-list">
|
||||||
|
@if todos.len == 0
|
||||||
|
<p>Nothing to see here...</p>
|
||||||
|
@endif
|
||||||
|
<!-- Loop over all the current todo's -->
|
||||||
|
@for todo in todos
|
||||||
|
<div class="todo">
|
||||||
|
<p class="name"><span class="todo-id">(id: @{todo.id})</span>@{todo.name}</p>
|
||||||
|
@if !todo.completed
|
||||||
|
<!-- We can also call methods of properties inside a template -->
|
||||||
|
<p class="time">Created at: <span class="time">@{todo.created.hhmmss()}</span></p>
|
||||||
|
<!-- Pass the id of the TODO as a route parameter to '/complete/:id' -->
|
||||||
|
<form action="/todo/@{todo.id}/complete" method="post">
|
||||||
|
<button class="success" type="submit">Complete</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<p class="time">Completed at: <span class="time">@{todo.updated.hhmmss()}</span></p>
|
||||||
|
<p class="completed">✔️</p>
|
||||||
|
<!-- Pass the id of the TODO as a route parameter to '/complete/:id' -->
|
||||||
|
<form action="/todo/@{todo.id}/delete" method="post">
|
||||||
|
<button class="error" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="create-todo">
|
||||||
|
<h2>Create a new TODO item</h2>
|
||||||
|
<form action="/" method="post">
|
||||||
|
<label for="task-name">Name:</label>
|
||||||
|
<input autofocus id="task-name" type="text" name="name">
|
||||||
|
<button class="primary" type="submit">Create</button>
|
||||||
|
<p class="form-error">@{ctx.form_error}</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -141,8 +141,9 @@ pub fn (app &App) login(mut ctx Context) vweb.Result {
|
|||||||
if password.len < 12 {
|
if password.len < 12 {
|
||||||
return ctx.text('password is too weak!')
|
return ctx.text('password is too weak!')
|
||||||
} else {
|
} else {
|
||||||
// redirect to the profile page
|
// we receive a POST request, so we want to explicitly tell the browser
|
||||||
return ctx.redirect('/profile')
|
// to send a GET request to the profile page.
|
||||||
|
return ctx.redirect('/profile', .see_other)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -751,13 +752,27 @@ pub fn (app &App) index(mut ctx Context) vweb.Result {
|
|||||||
|
|
||||||
#### Redirect
|
#### Redirect
|
||||||
|
|
||||||
|
You must pass the type of redirect to vweb:
|
||||||
|
- `moved_permanently` HTTP code 301
|
||||||
|
- `found` HTTP code 302
|
||||||
|
- `see_other` HTTP code 303
|
||||||
|
- `temporary_redirect` HTTP code 307
|
||||||
|
- `permanent_redirect` HTTP code 308
|
||||||
|
|
||||||
|
**Common use cases:**
|
||||||
|
|
||||||
|
If you want to change the request method, for example when you receive a post request and
|
||||||
|
want to redirect to another page via a GET request, you should use `see_other`. If you want
|
||||||
|
the HTTP method to stay the same you should use `found` generally speaking.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```v ignore
|
```v ignore
|
||||||
pub fn (app &App) index(mut ctx Context) vweb.Result {
|
pub fn (app &App) index(mut ctx Context) vweb.Result {
|
||||||
token := ctx.get_cookie('token') or { '' }
|
token := ctx.get_cookie('token') or { '' }
|
||||||
if token == '' {
|
if token == '' {
|
||||||
// redirect the user to '/login' if the 'token' cookie is not set
|
// redirect the user to '/login' if the 'token' cookie is not set
|
||||||
return ctx.redirect('/login')
|
// we explicitly tell the browser to send a GET request
|
||||||
|
return ctx.redirect('/login', .see_other)
|
||||||
} else {
|
} else {
|
||||||
return ctx.text('Welcome!')
|
return ctx.text('Welcome!')
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,14 @@ enum ContextReturnType {
|
|||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum RedirectType {
|
||||||
|
moved_permanently = int(http.Status.moved_permanently)
|
||||||
|
found = int(http.Status.found)
|
||||||
|
see_other = int(http.Status.see_other)
|
||||||
|
temporary_redirect = int(http.Status.temporary_redirect)
|
||||||
|
permanent_redirect = int(http.Status.permanent_redirect)
|
||||||
|
}
|
||||||
|
|
||||||
// The Context struct represents the Context which holds the HTTP request and response.
|
// The Context struct represents the Context which holds the HTTP request and response.
|
||||||
// It has fields for the query, form, files and methods for handling the request and response
|
// It has fields for the query, form, files and methods for handling the request and response
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
@ -213,10 +221,12 @@ pub fn (mut ctx Context) server_error(msg string) Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to an url
|
// Redirect to an url
|
||||||
pub fn (mut ctx Context) redirect(url string) Result {
|
pub fn (mut ctx Context) redirect(url string, redirect_type RedirectType) Result {
|
||||||
ctx.res.set_status(.found)
|
status := http.Status(redirect_type)
|
||||||
|
ctx.res.set_status(status)
|
||||||
|
|
||||||
ctx.res.header.add(.location, url)
|
ctx.res.header.add(.location, url)
|
||||||
return ctx.send_response_to_client('text/plain', '302 Found')
|
return ctx.send_response_to_client('text/plain', status.str())
|
||||||
}
|
}
|
||||||
|
|
||||||
// before_request is always the first function that is executed and acts as middleware
|
// before_request is always the first function that is executed and acts as middleware
|
||||||
|
@ -51,7 +51,7 @@ pub fn (mut app App) new_article(mut ctx Context) vweb.Result {
|
|||||||
insert article into Article
|
insert article into Article
|
||||||
} or {}
|
} or {}
|
||||||
|
|
||||||
return ctx.redirect('/')
|
return ctx.redirect('/', .see_other)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut app App) time(mut ctx Context) vweb.Result {
|
pub fn (mut app App) time(mut ctx Context) vweb.Result {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user