Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import time
|
||||
import veb
|
||||
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
// and https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
|
||||
// > Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows
|
||||
// > a server to indicate any origins (domain, scheme, or port) other than its own from
|
||||
// > which a browser should permit loading resources...
|
||||
|
||||
// Usage: do `./v run examples/xveb/cors/` to start the app,
|
||||
// then check the headers in another shell:
|
||||
//
|
||||
// 1) `curl -vvv -X OPTIONS http://localhost:45678/time`
|
||||
// 2) `curl -vvv -X POST http://localhost:45678/time`
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.Middleware[Context]
|
||||
}
|
||||
|
||||
// time is a simple POST request handler, that returns the current time. It should be available
|
||||
// to JS scripts, running on arbitrary other origins/domains.
|
||||
|
||||
@[post]
|
||||
pub fn (app &App) time() veb.Result {
|
||||
return ctx.json({
|
||||
'time': time.now().format_ss_milli()
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println("
|
||||
To test if CORS works, copy this JS snippet, then go to for example https://stackoverflow.com/ ,
|
||||
press F12, then paste the snippet in the opened JS console. You should see the veb server's time:
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.onload = function(data) {
|
||||
console.log('xhr loaded');
|
||||
console.log(xhr.response);
|
||||
};
|
||||
xhr.open('POST', 'http://localhost:45678/time');
|
||||
xhr.send();
|
||||
")
|
||||
|
||||
mut app := &App{}
|
||||
|
||||
// use veb's cors middleware to handle CORS requests
|
||||
app.use(veb.cors[Context](veb.CorsOptions{
|
||||
// allow CORS requests from every domain
|
||||
origins: ['*']
|
||||
// allow CORS requests with the following request methods:
|
||||
allowed_methods: [.get, .head, .patch, .put, .post, .delete]
|
||||
}))
|
||||
|
||||
veb.run[App, Context](mut app, 45678)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<b>custom template</b>
|
||||
@@ -0,0 +1,44 @@
|
||||
module main
|
||||
|
||||
import veb
|
||||
|
||||
const port = 8082
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
veb.run[App, Context](mut app, port)
|
||||
}
|
||||
|
||||
pub fn (mut app App) index() veb.Result {
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
@['/upload'; post]
|
||||
pub fn (mut app App) upload(mut ctx Context) veb.Result {
|
||||
fdata := ctx.files['upfile']
|
||||
|
||||
data_rows := fdata[0].data.split('\n')
|
||||
|
||||
mut output_data := ''
|
||||
|
||||
for elem in data_rows {
|
||||
delim_row := elem.split('\t')
|
||||
output_data += '${delim_row[0]}\t${delim_row[1]}\t${delim_row[0].int() + delim_row[1].int()}\n'
|
||||
}
|
||||
|
||||
output_data = output_data.all_before_last('\n')
|
||||
|
||||
println(output_data)
|
||||
|
||||
ctx.set_header(.content_disposition, 'attachment; filename=results.txt')
|
||||
ctx.send_response_to_client('application/octet-stream', output_data)
|
||||
|
||||
return $veb.html()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Test file uploading data transformation</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>File Upload and Data Transformation</h2>
|
||||
|
||||
Upload the <b>sample_input.txt</b> located in this example folder.
|
||||
<br>
|
||||
<br>
|
||||
V will apply data transformation, adding the first two columns into a third column, and return the results as a download. <br>
|
||||
<br>
|
||||
|
||||
For example:
|
||||
<table style="color:blue;">
|
||||
<tr>
|
||||
<td>10</td>
|
||||
<td>13</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>20</td>
|
||||
<td>54</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>30</td>
|
||||
<td>82</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>40</td>
|
||||
<td>11</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>50</td>
|
||||
<td>47</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
Becomes...
|
||||
<br>
|
||||
<table style="color:blue;">
|
||||
<tr>
|
||||
<td>10</td>
|
||||
<td>13</td>
|
||||
<td>23</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>20</td>
|
||||
<td>54</td>
|
||||
<td>74</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>30</td>
|
||||
<td>82</td>
|
||||
<td>112</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>40</td>
|
||||
<td>11</td>
|
||||
<td>51</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>50</td>
|
||||
<td>47</td>
|
||||
<td>97</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
File form:
|
||||
<form method="POST" enctype="multipart/form-data" action="http://localhost:8082/upload">
|
||||
<input type="file" name="upfile"/>
|
||||
<input type="submit" value="Upload & Transform"/>
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
10 13
|
||||
20 54
|
||||
30 82
|
||||
40 11
|
||||
50 47
|
||||
@@ -0,0 +1,2 @@
|
||||
<meta charset="utf-8">
|
||||
<a href="/">Back</a>
|
||||
@@ -0,0 +1,46 @@
|
||||
module main
|
||||
|
||||
import net.http
|
||||
import veb
|
||||
|
||||
const port = 8082
|
||||
|
||||
struct App {
|
||||
}
|
||||
|
||||
struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
fn uploaded_file_contents(fdata []http.FileData) []veb.RawHtml {
|
||||
mut files := []veb.RawHtml{}
|
||||
for data in fdata {
|
||||
files << data.data.replace_each(['\n', '<br>', '\n\r', '<br>', '\t', ' ', ' ', ' '])
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
veb.run[App, Context](mut app, port)
|
||||
}
|
||||
|
||||
pub fn (mut app App) index() veb.Result {
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
@['/upload'; post]
|
||||
pub fn (mut app App) upload() veb.Result {
|
||||
dump(ctx.form)
|
||||
dump(ctx.files)
|
||||
fdata := ctx.files['upfile']
|
||||
files := uploaded_file_contents(fdata)
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
@['/submit'; post]
|
||||
pub fn (mut app App) submit() veb.Result {
|
||||
dump(ctx.form)
|
||||
form_data := ctx.form.clone()
|
||||
return $veb.html()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Test multipart/form-data file uploading</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>File Upload</h2>
|
||||
|
||||
File form:
|
||||
<form method="POST" enctype="multipart/form-data" action="/upload">
|
||||
<input type="hidden" name="file_id" value="aafueB5Quie" />
|
||||
<input type="file" name="upfile" multiple/>
|
||||
<input type="hidden" name="another_hidden_field" value="abcxyz" />
|
||||
<input type="submit" value="Upload"/>
|
||||
</form>
|
||||
|
||||
Text form:
|
||||
<form method="POST" enctype="multipart/form-data" action="/submit">
|
||||
<input type="text" name="content1" value="abc" />
|
||||
<input type="text" name="content2" value="xyz" />
|
||||
<input type="submit" value="Submit content">
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<meta charset="utf-8">
|
||||
|
||||
Submitted form info:
|
||||
|
||||
@for name, value in form_data
|
||||
|
||||
<h2>name: @name</h2>
|
||||
<p>value: @value</p>
|
||||
|
||||
@end
|
||||
|
||||
<a href="/">Back</a>
|
||||
@@ -0,0 +1,15 @@
|
||||
<meta charset="utf-8">
|
||||
|
||||
File amount: @fdata.len
|
||||
|
||||
@for i, data in fdata
|
||||
|
||||
<h2>Filename: @data.filename</h2>
|
||||
<h2>Length: @data.data.len</h2>
|
||||
<h2>Type: @data.content_type</h2>
|
||||
|
||||
<p>@{files[i]}</p>
|
||||
|
||||
@end
|
||||
|
||||
<a href="/">Back</a>
|
||||
@@ -0,0 +1,4 @@
|
||||
<br/><br/>
|
||||
footer
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta charset=utf-8>
|
||||
<title>veb example page</title>
|
||||
</head>
|
||||
<body>
|
||||
header from included header.html <br><br>
|
||||
@@ -0,0 +1,24 @@
|
||||
@include 'header.html'
|
||||
|
||||
Test <b>app</b>
|
||||
<br>
|
||||
<h1>@hello</h1>
|
||||
<hr>
|
||||
|
||||
If demo: <br>
|
||||
@if show
|
||||
show = true
|
||||
@end
|
||||
|
||||
<br><br>
|
||||
|
||||
For loop demo: <br>
|
||||
|
||||
@for number in numbers
|
||||
@number <br>
|
||||
@end
|
||||
|
||||
|
||||
<hr>
|
||||
End.
|
||||
@include 'footer.html'
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@title</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/admin/secrets">"/admin/secrets"</a>
|
||||
<a href="/admin/dynamic">"/admin/dynamic"</a>
|
||||
<a href="/early">Early exit</a>
|
||||
</nav>
|
||||
<main>
|
||||
@content
|
||||
</main>
|
||||
<footer></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<p>Early exit</p>
|
||||
@@ -0,0 +1 @@
|
||||
<h1>Hello </h1>
|
||||
@@ -0,0 +1 @@
|
||||
<p>Super secret stuff</p>
|
||||
@@ -0,0 +1,95 @@
|
||||
module main
|
||||
|
||||
import veb
|
||||
|
||||
// for another example see vlib/veb/tests/middleware_test.v
|
||||
const http_port = 8080
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
mut:
|
||||
is_authenticated bool
|
||||
}
|
||||
|
||||
struct App {
|
||||
veb.Middleware[Context]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
|
||||
// chaining is allowed, middleware will be evaluated in order
|
||||
app.Middleware.use(handler: other_func1)
|
||||
app.Middleware.use(handler: other_func2)
|
||||
|
||||
// route-specific middleware
|
||||
app.Middleware.route_use('/admin/:path...', handler: check_auth)
|
||||
app.Middleware.route_use('/early', handler: middleware_early)
|
||||
|
||||
veb.run[App, Context](mut app, http_port)
|
||||
}
|
||||
|
||||
@['/']
|
||||
pub fn (app &App) index(mut ctx Context) veb.Result {
|
||||
println('Index page')
|
||||
title := 'Home Page'
|
||||
|
||||
content := $tmpl('templates/index.html')
|
||||
base := $tmpl('templates/base.html')
|
||||
return ctx.html(base)
|
||||
}
|
||||
|
||||
@['/admin/secrets']
|
||||
pub fn (app &App) secrets(mut ctx Context) veb.Result {
|
||||
println('Secrets page')
|
||||
title := 'Secret Admin Page'
|
||||
|
||||
content := $tmpl('templates/secret.html')
|
||||
base := $tmpl('templates/base.html')
|
||||
return ctx.html(base)
|
||||
}
|
||||
|
||||
@['/admin/:sub']
|
||||
pub fn (app &App) dynamic(mut ctx Context, sub string) veb.Result {
|
||||
println('Dynamic page')
|
||||
title := 'Secret dynamic'
|
||||
|
||||
content := sub
|
||||
base := $tmpl('templates/base.html')
|
||||
return ctx.html(base)
|
||||
}
|
||||
|
||||
@['/early']
|
||||
pub fn (app &App) early(mut ctx Context) veb.Result {
|
||||
println('Early page')
|
||||
title := 'Early Exit'
|
||||
|
||||
content := $tmpl('templates/early.html')
|
||||
base := $tmpl('templates/base.html')
|
||||
return ctx.html(base)
|
||||
}
|
||||
|
||||
fn other_func1(mut _ Context) bool {
|
||||
println('1')
|
||||
return true
|
||||
}
|
||||
|
||||
fn other_func2(mut _ Context) bool {
|
||||
println('2')
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_auth(mut ctx Context) bool {
|
||||
println('3')
|
||||
if ctx.is_authenticated == false {
|
||||
ctx.redirect('/')
|
||||
}
|
||||
return ctx.is_authenticated
|
||||
}
|
||||
|
||||
fn middleware_early(mut ctx Context) bool {
|
||||
println('4')
|
||||
ctx.text(':(')
|
||||
// returns false, so the middleware propagation is stopped and the user will see the text ":("
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
background-image: url("v-logo.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-size: 10em;
|
||||
margin: 0;
|
||||
padding-left: 11em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #6699CC;
|
||||
}
|
||||
|
||||
img.logo {
|
||||
float: left;
|
||||
width: 10em;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#536b8a" d="M 323.509,471.167 480.202,32.185 C 482.857,24.74 478.671,19.312 470.853,20.071 L 347.629,32.03 c -7.816,0.759 -16.259,7.44 -18.841,14.911 L 183.753,466.465 c -2.583,7.47 1.693,13.535 9.546,13.535 h 119.938 c 3.925,0 8.192,-3.02 9.52,-6.744 l 0.746,-2.089 v 0 0 z"/>
|
||||
<path fill="#5d87be" d="M 30.055,20.07 152.165,32.029 c 7.745,0.76 16.122,7.438 18.692,14.905 l 146.766,426.302 c 1.284,3.735 -0.83,6.764 -4.721,6.764 H 193.294 c -7.781,0 -16.239,-6.044 -18.871,-13.489 L 20.791,32.185 c -2.633,-7.445 1.518,-12.874 9.264,-12.114 v 0 0 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,38 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>@title</title>
|
||||
<meta charset="utf-8"/>
|
||||
@css 'assets/site.css'
|
||||
</head>
|
||||
<body>
|
||||
<h1>@title</h1>
|
||||
<button>Close the connection</button>
|
||||
<ul></ul>
|
||||
<script>
|
||||
"use strict";
|
||||
var button = document.querySelector('button');
|
||||
var eventList = document.querySelector('ul');
|
||||
const evtSource = new EventSource('/sse');
|
||||
evtSource.onerror = function() { console.log("EventSource failed."); };
|
||||
console.log(evtSource.withCredentials);
|
||||
console.log(evtSource.readyState);
|
||||
console.log(evtSource.url);
|
||||
evtSource.onopen = function() {
|
||||
console.log("Connection to server opened.");
|
||||
};
|
||||
evtSource.onmessage = function(e) {
|
||||
var newElement = document.createElement("li");
|
||||
newElement.textContent = "message: " + e.data;
|
||||
eventList.appendChild(newElement);
|
||||
};
|
||||
evtSource.addEventListener("ping", function(e) {
|
||||
console.log(e)
|
||||
var newElement = document.createElement("li");
|
||||
var obj = JSON.parse(e.data);
|
||||
newElement.innerHTML = "ping at " + obj.time + ' server data: ' + e.data;
|
||||
eventList.appendChild(newElement);
|
||||
}, false);
|
||||
button.onclick = function() { console.log('Connection closed'); evtSource.close(); };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
module main
|
||||
|
||||
import os
|
||||
import rand
|
||||
import time
|
||||
import veb
|
||||
import veb.sse
|
||||
|
||||
struct App {
|
||||
veb.StaticHandler
|
||||
}
|
||||
|
||||
struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
app.serve_static('/favicon.ico', 'favicon.ico')!
|
||||
app.mount_static_folder_at(os.resource_abs_path('.'), '/')!
|
||||
veb.run[App, Context](mut app, 8081)
|
||||
}
|
||||
|
||||
// XTODO template broken (@)
|
||||
pub fn (mut app App) index() veb.Result {
|
||||
title := 'SSE Example'
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
fn (mut app App) sse() veb.Result {
|
||||
ctx.takeover_conn()
|
||||
spawn handle_sse_conn(mut ctx)
|
||||
return veb.no_result()
|
||||
}
|
||||
|
||||
fn handle_sse_conn(mut ctx Context) {
|
||||
mut session := sse.start_connection(mut ctx.Context)
|
||||
session.send_message(data: 'ok') or { return }
|
||||
for {
|
||||
data := '{"time": "${time.now().str()}", "random_id": "${rand.ulid()}"}'
|
||||
session.send_message(event: 'ping', data: data) or { break }
|
||||
println('> sent event: ${data}')
|
||||
time.sleep(1 * time.second)
|
||||
}
|
||||
session.close()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Host a static website
|
||||
|
||||
Here is an example on how to use the Veb server's static capabilities,
|
||||
to host a static website from the `dist/` folder. Just run the server,
|
||||
it will be available at http://localhost:8080/ :
|
||||
|
||||
```bash
|
||||
v run server.v
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Website other page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello, another world!</h1>
|
||||
<p>Welcome to this another page of a demo website.</p>
|
||||
<p><a href="/">Click here</a> to go to main page.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Website main page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<p>Welcome to this demo website.</p>
|
||||
<p><a href="another.html">Click here</a> to go to another page.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
module main
|
||||
|
||||
import veb
|
||||
import os
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// make sure that the working folder is the one, containing the executable,
|
||||
// so that 'dist' is a valid relative path from it later:
|
||||
os.chdir(os.dir(os.executable()))!
|
||||
mut app := &App{}
|
||||
app.handle_static('dist', true)!
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*.db
|
||||
@@ -0,0 +1,15 @@
|
||||
# A simple TODO app using veb
|
||||
|
||||
A simple TODO app using `veb` showcasing how to build a basic web app with veb.
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Simple TODO app using veb
|
||||
// Run from this directory with `v run main.v`
|
||||
// You can also enable vebs livereload feature with
|
||||
// `v watch -d veb_livereload run main.v`
|
||||
module main
|
||||
|
||||
import veb
|
||||
import db.sqlite
|
||||
import os
|
||||
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 {
|
||||
veb.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 {
|
||||
veb.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) veb.Result {
|
||||
todos := sql app.db {
|
||||
select from Todo
|
||||
} or { return ctx.server_error('could not fetch todos from database!') }
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
// This method will only handle POST requests to the index page
|
||||
@['/'; post]
|
||||
pub fn (app &App) create_todo(mut ctx Context, name string) veb.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 == '' {
|
||||
// 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 database') }
|
||||
|
||||
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) veb.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('/', typ: .see_other)
|
||||
}
|
||||
|
||||
@['/todo/:id/delete'; post]
|
||||
pub fn (app &App) delete_todo(mut ctx Context, id int) veb.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 {
|
||||
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('/', typ: .see_other)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
os.chdir(os.dir(@FILE))!
|
||||
// create a new App instance with a connection to the database
|
||||
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
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,25 @@
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
background-image: url("v-logo.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-size: 10em;
|
||||
margin: 0;
|
||||
padding-left: 11em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #6699CC;
|
||||
}
|
||||
|
||||
img.logo {
|
||||
float: left;
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
html {
|
||||
display:block;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#536b8a" d="M 323.509,471.167 480.202,32.185 C 482.857,24.74 478.671,19.312 470.853,20.071 L 347.629,32.03 c -7.816,0.759 -16.259,7.44 -18.841,14.911 L 183.753,466.465 c -2.583,7.47 1.693,13.535 9.546,13.535 h 119.938 c 3.925,0 8.192,-3.02 9.52,-6.744 l 0.746,-2.089 v 0 0 z"/>
|
||||
<path fill="#5d87be" d="M 30.055,20.07 152.165,32.029 c 7.745,0.76 16.122,7.438 18.692,14.905 l 146.766,426.302 c 1.284,3.735 -0.83,6.764 -4.721,6.764 H 193.294 c -7.781,0 -16.239,-6.044 -18.871,-13.489 L 20.791,32.185 c -2.633,-7.445 1.518,-12.874 9.264,-12.114 v 0 0 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>@title</title>
|
||||
<style>html{display:none}</style>
|
||||
@css 'index.css'
|
||||
</head>
|
||||
<body>
|
||||
<h1>@title</h1>
|
||||
<h2>@subtitle</h2>
|
||||
<p>@message</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
module main
|
||||
|
||||
import os
|
||||
import veb
|
||||
// import veb.assets
|
||||
import time
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
app.serve_static('/favicon.ico', 'favicon.ico')!
|
||||
// Automatically make available known static mime types found in given directory.
|
||||
os.chdir(os.dir(os.executable()))!
|
||||
app.handle_static('assets', true)!
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
|
||||
pub fn (mut app App) index(mut ctx Context) veb.Result {
|
||||
// We can dynamically specify which assets are to be used in template.
|
||||
// mut am := assets.new_manager()
|
||||
// am.add_css('assets/index.css')
|
||||
// css := am.include_css(false)
|
||||
title := 'Veb Assets Example'
|
||||
subtitle := 'Veb can serve static assets too!'
|
||||
message := 'It also has an Assets Manager that allows dynamically specifying which CSS and JS files to be used.'
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
fn (mut app App) text(mut ctx Context) veb.Result {
|
||||
return ctx.text('Hello, world from veb!')
|
||||
}
|
||||
|
||||
fn (mut app App) time(mut ctx Context) veb.Result {
|
||||
return ctx.text(time.now().format())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
module main
|
||||
|
||||
import os
|
||||
import veb
|
||||
// import veb.assets
|
||||
import time
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
app.serve_static('/favicon.ico', 'favicon.ico')!
|
||||
// Automatically make available known static mime types found in given directory.
|
||||
os.chdir(os.dir(os.executable()))!
|
||||
app.handle_static('assets', true)!
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
|
||||
pub fn (mut app App) index(mut ctx Context) veb.Result {
|
||||
// We can dynamically specify which assets are to be used in template.
|
||||
// mut am := assets.new_manager()
|
||||
// am.add_css('assets/index.css')
|
||||
// css := am.include_css(false)
|
||||
title := 'Veb Assets Example'
|
||||
subtitle := 'Veb can serve static assets too!'
|
||||
message := 'It also has an Assets Manager that allows dynamically specifying which CSS and JS files to be used.'
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
fn (mut app App) text(mut ctx Context) veb.Result {
|
||||
return ctx.text('Hello, world from veb!')
|
||||
}
|
||||
|
||||
fn (mut app App) time(mut ctx Context) veb.Result {
|
||||
return ctx.text(time.now().format())
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
module main
|
||||
|
||||
import veb
|
||||
import rand
|
||||
|
||||
const port = 8082
|
||||
|
||||
struct State {
|
||||
mut:
|
||||
cnt int
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
mut:
|
||||
state shared State
|
||||
}
|
||||
|
||||
struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub fn (ctx &Context) before_request() {
|
||||
$if trace_before_request ? {
|
||||
eprintln('[veb] before_request: ${ctx.req.method} ${ctx.req.url}')
|
||||
}
|
||||
}
|
||||
|
||||
@['/users/:user']
|
||||
pub fn (mut app App) user_endpoint(mut ctx Context, user string) veb.Result {
|
||||
// pub fn (mut app App) user_endpoint(user string) veb.Result {
|
||||
id := rand.intn(100) or { 0 }
|
||||
return ctx.json({
|
||||
user: id
|
||||
})
|
||||
}
|
||||
|
||||
pub fn (mut app App) index() veb.Result {
|
||||
println('veb_example.v index()')
|
||||
mut c := 0
|
||||
lock app.state {
|
||||
app.state.cnt++
|
||||
c = app.state.cnt
|
||||
//
|
||||
$if trace_address_of_app_state_cnt ? {
|
||||
dump(ptr_str(app.state.cnt))
|
||||
}
|
||||
}
|
||||
show := true
|
||||
hello := 'Hello world from veb, request number: ${c}'
|
||||
numbers := [1, 2, 3]
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
pub fn (mut app App) custom_template(mut ctx Context) veb.Result {
|
||||
return $veb.html('custom.html')
|
||||
}
|
||||
|
||||
pub fn (mut app App) show_text(mut ctx Context) veb.Result {
|
||||
return ctx.text('Hello world from veb')
|
||||
}
|
||||
|
||||
pub fn (mut app App) cookie(mut ctx Context) veb.Result {
|
||||
ctx.set_cookie(name: 'cookie', value: 'test')
|
||||
return ctx.text('Response Headers\n${ctx.req.header}')
|
||||
}
|
||||
|
||||
@[post]
|
||||
pub fn (mut app App) post(mut ctx Context) veb.Result {
|
||||
return ctx.text('Post body: ${ctx.req.data}')
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('veb example')
|
||||
mut app := &App{}
|
||||
// veb.run(&App{}, port)
|
||||
|
||||
veb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2) or {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,41 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
*, *:before, *:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
body {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
#message-list {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
margin: 0px;
|
||||
margin-bottom: 5px;
|
||||
border: 1px solid lightgray;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior-y: contain;
|
||||
scroll-snap-type: y proximity;
|
||||
}
|
||||
#message-list > li {
|
||||
list-style-type: none;
|
||||
}
|
||||
#message-list > li:last-child {
|
||||
scroll-snap-align: end;
|
||||
border-top: 1px solid #eeeeff !important;
|
||||
}
|
||||
|
||||
#message-input {
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta charset=utf-8>
|
||||
<title>veb websocket example</title>
|
||||
<link rel="stylesheet" type="text/css" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<p>This is an example of using V's `veb` and `net.websocket` modules in
|
||||
the same app, where the websocket connection happens on the /ws route of
|
||||
your veb app.</p>
|
||||
<ul id="message-list"></ul>
|
||||
<input id="message-input" placeholder="Enter your message here" value=""/>
|
||||
<div>Client status: <span id="message-status">---</span></div>
|
||||
<script type="text/javascript" src="/assets/client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
module main
|
||||
|
||||
import os
|
||||
import veb
|
||||
import log
|
||||
import time
|
||||
import term
|
||||
import net
|
||||
import net.http
|
||||
import net.websocket
|
||||
|
||||
const app_port = 8990
|
||||
|
||||
fn main() {
|
||||
log.use_stdout()
|
||||
mut app := &App{
|
||||
wss: new_websocker_server()!
|
||||
}
|
||||
app.mount_static_folder_at(os.resource_abs_path('assets'), '/assets')!
|
||||
app.serve_static('/favicon.ico', os.resource_abs_path('assets/favicon.ico'))!
|
||||
veb.run[App, Context](mut app, app_port)
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
mut:
|
||||
wss &websocket.Server
|
||||
}
|
||||
|
||||
pub fn (mut app App) index(mut ctx Context) veb.Result {
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
pub fn (mut app App) ws(mut ctx Context) veb.Result {
|
||||
key := ctx.get_header(http.CommonHeader.sec_websocket_key) or { '' }
|
||||
if key == '' {
|
||||
ctx.error('Invalid websocket handshake. Key is missing.')
|
||||
return ctx.redirect('/')
|
||||
}
|
||||
dump(ctx.req.cookie('token') or { http.Cookie{} }.value)
|
||||
wlog('> transferring connection with key: ${key}, to the websocket server ${voidptr(app.wss)} ...')
|
||||
ctx.takeover_conn()
|
||||
ctx.conn.set_write_timeout(time.infinite)
|
||||
ctx.conn.set_read_timeout(time.infinite)
|
||||
spawn fn (mut wss websocket.Server, mut connection net.TcpConn, key string) {
|
||||
wss.handle_handshake(mut connection, key) or { wlog('handle_handshake error: ${err}') }
|
||||
wlog('>> wss.handle_handshake finished, key: ${key}')
|
||||
}(mut app.wss, mut ctx.conn, key)
|
||||
wlog('> done transferring connection')
|
||||
return veb.no_result()
|
||||
}
|
||||
|
||||
fn slog(message string) {
|
||||
eprintln(term.colorize(term.bright_yellow, message))
|
||||
}
|
||||
|
||||
fn wlog(message string) {
|
||||
eprintln(term.colorize(term.bright_blue, message))
|
||||
}
|
||||
|
||||
fn new_websocker_server() !&websocket.Server {
|
||||
mut logger := &log.Log{}
|
||||
logger.set_level(.info)
|
||||
logger.set_output_stream(os.stderr())
|
||||
mut wss := websocket.new_server(.ip, app_port, '', logger: logger)
|
||||
wss.set_ping_interval(100)
|
||||
wss.on_connect(fn [mut logger] (mut server_client websocket.ServerClient) !bool {
|
||||
server_client.client.logger = logger
|
||||
slog('wss.on_connect client.id: ${server_client.client.id} | server_client.client_key: ${server_client.client_key}')
|
||||
return true
|
||||
})!
|
||||
wss.on_close(fn (mut client websocket.Client, code int, reason string) ! {
|
||||
slog('wss.on_close client.id: ${client.id} | code: ${code}, reason: ${reason}')
|
||||
})
|
||||
wss.on_message(fn [mut wss] (mut client websocket.Client, msg &websocket.Message) ! {
|
||||
slog('wss.on_message client.id: ${client.id} | msg.opcode: ${msg.opcode} | msg.payload: `${msg.payload.bytestr()}`')
|
||||
text := '${client.id} says: "${msg.payload.bytestr()}"'
|
||||
// client.write_string(text) or { slog('client.write err: ${err}') return err }
|
||||
for i, _ in rlock wss.server_state {
|
||||
wss.server_state.clients
|
||||
} {
|
||||
mut c := rlock wss.server_state {
|
||||
wss.server_state.clients[i] or { continue }
|
||||
}
|
||||
if c.client.get_state() == .open {
|
||||
c.client.write_string(text) or {
|
||||
slog('error while broadcasting to i: ${i}, ${voidptr(c)}, err: ${err}')
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
slog('Websocket Server initialized, wss: ${voidptr(wss)}')
|
||||
return wss
|
||||
}
|
||||
Reference in new issue
Block a user