~repos /gromer

#golang#htmx#ssr

git clone https://pyrossh.dev/repos/gromer.git

gromer is a framework and cli to build multipage web apps in golang using htmx and alpinejs.


6eb01df1 Peter John

3 years ago
fix various issues
_example/components/Checkbox.go ADDED
@@ -0,0 +1,41 @@
1
+ package components
2
+
3
+ import (
4
+ . "github.com/pyros2097/gromer/gsx"
5
+ )
6
+
7
+ var _ = Css(`
8
+ .checkbox {
9
+ text-align: center;
10
+ width: 40px;
11
+ /* auto, since non-WebKit browsers doesn't support input styling */
12
+ height: auto;
13
+ position: absolute;
14
+ top: 0;
15
+ bottom: 0;
16
+ margin: auto 0;
17
+ border: none; /* Mobile Safari */
18
+ -webkit-appearance: none;
19
+ appearance: none;
20
+ }
21
+
22
+ .checkbox {
23
+ opacity: 0;
24
+ }
25
+
26
+ .checkbox + label {
27
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
28
+ background-repeat: no-repeat;
29
+ background-position: center left;
30
+ }
31
+
32
+ .checkbox:checked + label {
33
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
34
+ }
35
+ `)
36
+
37
+ func Checkbox(c Context, value bool) *Node {
38
+ return c.Render(`
39
+ <input class="checkbox" type="checkbox" checked="{value}" />
40
+ `)
41
+ }
_example/components/todo.go CHANGED
@@ -8,6 +8,8 @@ import (
8
8
  var _ = Css(`
9
9
  `)
10
10
 
11
+ // <Checkbox hx-post="/" value="{todo.Completed}" />
12
+
11
13
  func Todo(c Context, todo *todos.Todo) *Node {
12
14
  return c.Render(`
13
15
  <li id="todo-{todo.ID}" class="{ completed: todo.Completed }">
@@ -15,7 +17,7 @@ func Todo(c Context, todo *todos.Todo) *Node {
15
17
  <form hx-target="#todo-{todo.ID}" hx-swap="outerHTML">
16
18
  <input type="hidden" name="intent" value="complete" />
17
19
  <input type="hidden" name="id" value="{todo.ID}" />
18
- <input hx-post="/" class="checkbox" type="checkbox" checked="{ completed: todo.Completed }" />
20
+ <input class="checkbox" type="checkbox" checked="{value}" />
19
21
  </form>
20
22
  <label>{todo.Text}</label>
21
23
  <form hx-post="/" hx-target="#todo-{todo.ID}" hx-swap="delete">
@@ -27,5 +29,3 @@ func Todo(c Context, todo *todos.Todo) *Node {
27
29
  </li>
28
30
  `)
29
31
  }
30
-
31
- // <!-- <label hx-get="/todos/edit/{todo.ID}" hx-target="#todo-{todo.ID}" hx-swap="outerHTML">{{ props.Todo.Text }}</label> -->
_example/containers/TodoCount.go CHANGED
@@ -26,7 +26,7 @@ func TodoCount(c Context, filter string) (*Node, error) {
26
26
  }
27
27
  c.Set("count", len(todos))
28
28
  return c.Render(`
29
- <span class="todo-count" id="todo-count" hx-swap-oob="true">
29
+ <span id="todo-count" class="todo-count" hx-swap-oob="true">
30
30
  <strong>{count}</strong> item left
31
31
  </span>
32
32
  `), nil
_example/containers/TodoList.go CHANGED
@@ -39,34 +39,6 @@ var _ = Css(`
39
39
  display: none;
40
40
  }
41
41
 
42
- .todo-list li .toggle {
43
- text-align: center;
44
- width: 40px;
45
- /* auto, since non-WebKit browsers doesn't support input styling */
46
- height: auto;
47
- position: absolute;
48
- top: 0;
49
- bottom: 0;
50
- margin: auto 0;
51
- border: none; /* Mobile Safari */
52
- -webkit-appearance: none;
53
- appearance: none;
54
- }
55
-
56
- .todo-list li .toggle {
57
- opacity: 0;
58
- }
59
-
60
- .todo-list li .toggle + label {
61
- background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
62
- background-repeat: no-repeat;
63
- background-position: center left;
64
- }
65
-
66
- .todo-list li .toggle:checked + label {
67
- background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
68
- }
69
-
70
42
  .todo-list li label {
71
43
  word-break: break-all;
72
44
  padding: 15px 15px 15px 60px;
_example/main.go CHANGED
@@ -19,6 +19,7 @@ import (
19
19
 
20
20
  func init() {
21
21
  gsx.RegisterComponent(components.Todo, "todo")
22
+ gsx.RegisterComponent(components.Checkbox, "value")
22
23
 
23
24
  gsx.RegisterComponent(containers.TodoCount, "filter")
24
25
  gsx.RegisterComponent(containers.TodoList, "page", "filter")
_example/routes/404/get.go CHANGED
@@ -1,20 +1,17 @@
1
1
  package not_found_404
2
2
 
3
3
  import (
4
- "context"
5
-
6
4
  . "github.com/pyros2097/gromer/gsx"
7
5
  )
8
6
 
9
- func GET(h Context, c context.Context) (*Node, int, error) {
7
+ func GET(c Context) (*Node, int, error) {
8
+ c.Meta("title", "Page Not Found")
10
- return h.Render(`
9
+ return c.Render(`
11
- <Page title="Page Not Found">
12
- <main class="box center">
10
+ <main class="box center">
13
- <h1>Page Not Found</h1>
11
+ <h1>Page Not Found</h1>
14
- <h2 class="mt-6">
12
+ <h2 class="mt-6">
15
- <a class="is-underlined" href="/">Go Back</a>
13
+ <a class="is-underlined" href="/">Go Back</a>
16
- </h2>
14
+ </h2>
17
- </main>
15
+ </main>
18
- </Page>
19
16
  `), 404, nil
20
17
  }
_example/routes/about/get.go CHANGED
@@ -1,18 +1,16 @@
1
1
  package about
2
2
 
3
3
  import (
4
- "context"
5
-
6
4
  . "github.com/pyros2097/gromer/gsx"
7
5
  )
8
6
 
9
- func GET(h Context, c context.Context) (*Node, int, error) {
7
+ func GET(c Context) (*Node, int, error) {
8
+ c.Meta("title", "About Gromer")
9
+ c.Meta("description", "About Gromer")
10
- return h.Render(`
10
+ return c.Render(`
11
- <Page title="About me">
12
- <div class="flex flex-col justify-center items-center">
11
+ <div class="flex flex-col justify-center items-center">
13
- A new link is here
12
+ A new link is here
14
- P<h1>About Me</h1>
13
+ P<h1>About Me</h1>
15
- </div>
14
+ </div>
16
- </Page>
17
15
  `), 200, nil
18
16
  }
_example/routes/get.go CHANGED
@@ -1,314 +1,36 @@
1
1
  package routes
2
2
 
3
3
  import (
4
- "context"
5
-
6
4
  _ "github.com/pyros2097/gromer/_example/components"
7
5
  . "github.com/pyros2097/gromer/gsx"
8
6
  )
9
7
 
10
- var _ = Css(`
11
- hr {
12
- margin: 20px 0;
13
- border: 0;
14
- border-top: 1px dashed #c5c5c5;
15
- border-bottom: 1px dashed #f7f7f7;
16
- }
17
-
18
- html,
19
- body {
20
- margin: 0;
21
- padding: 0;
22
- }
23
-
24
- button {
25
- margin: 0;
26
- padding: 0;
27
- border: 0;
28
- background: none;
29
- font-size: 100%;
30
- vertical-align: baseline;
31
- font-family: inherit;
32
- font-weight: inherit;
33
- color: inherit;
34
- -webkit-appearance: none;
35
- appearance: none;
36
- -webkit-font-smoothing: antialiased;
37
- -moz-osx-font-smoothing: grayscale;
38
- }
39
-
40
- body {
41
- font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
42
- line-height: 1.4em;
43
- background: #f5f5f5;
44
- color: #4d4d4d;
45
- min-width: 230px;
46
- max-width: 550px;
47
- margin: 0 auto;
48
- -webkit-font-smoothing: antialiased;
49
- -moz-osx-font-smoothing: grayscale;
50
- font-weight: 300;
51
- }
52
-
53
- :focus {
54
- outline: 0;
55
- }
56
-
57
- .hidden {
58
- display: none;
59
- }
60
-
61
- .new-todo,
62
- .edit {
63
- position: relative;
64
- margin: 0;
65
- width: 100%;
66
- font-size: 24px;
67
- font-family: inherit;
68
- font-weight: inherit;
69
- line-height: 1.4em;
70
- border: 0;
71
- color: inherit;
72
- padding: 6px;
73
- border: 1px solid #999;
74
- box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
75
- box-sizing: border-box;
76
- -webkit-font-smoothing: antialiased;
77
- -moz-osx-font-smoothing: grayscale;
78
- }
79
-
80
- .new-todo {
81
- padding: 16px 16px 16px 60px;
82
- border: none;
83
- background: rgba(0, 0, 0, 0.003);
84
- box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
85
- }
86
-
87
- .main {
88
- position: relative;
89
- z-index: 2;
90
- border-top: 1px solid #e6e6e6;
91
- }
92
-
93
- .toggle-all {
94
- text-align: center;
95
- border: none; /* Mobile Safari */
96
- opacity: 0;
97
- position: absolute;
98
- }
99
-
100
- .toggle-all + label {
101
- width: 60px;
102
- height: 34px;
103
- font-size: 0;
104
- position: absolute;
105
- top: -52px;
106
- left: -13px;
107
- -webkit-transform: rotate(90deg);
108
- transform: rotate(90deg);
109
- }
110
-
111
- .toggle-all + label:before {
112
- content: '❯';
113
- font-size: 22px;
114
- color: #e6e6e6;
115
- padding: 10px 27px 10px 27px;
116
- }
117
-
118
- .toggle-all:checked + label:before {
119
- color: #737373;
120
- }
121
-
122
- .footer {
123
- color: #777;
124
- padding: 10px 15px;
125
- height: 20px;
126
- text-align: center;
127
- border-top: 1px solid #e6e6e6;
128
- }
129
-
130
- .footer:before {
131
- content: '';
132
- position: absolute;
133
- right: 0;
134
- bottom: 0;
135
- left: 0;
136
- height: 50px;
137
- overflow: hidden;
138
- box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2);
139
- }
140
-
141
- .filters {
142
- margin: 0;
143
- padding: 0;
144
- list-style: none;
145
- position: absolute;
146
- right: 0;
147
- left: 0;
148
- }
149
-
150
- .filters li {
151
- display: inline;
152
- }
153
-
154
- .filters li a {
155
- color: inherit;
156
- margin: 3px;
157
- padding: 3px 7px;
158
- text-decoration: none;
159
- border: 1px solid transparent;
160
- border-radius: 3px;
161
- }
162
-
163
- .filters li a:hover {
164
- border-color: rgba(175, 47, 47, 0.1);
165
- }
166
-
167
- .filters li a.selected {
168
- border-color: rgba(175, 47, 47, 0.2);
169
- }
170
-
171
- .clear-completed,
172
- html .clear-completed:active {
173
- float: right;
174
- position: relative;
175
- line-height: 20px;
176
- text-decoration: none;
177
- cursor: pointer;
178
- }
179
-
180
- .clear-completed:hover {
181
- text-decoration: underline;
182
- }
183
-
184
- .info {
185
- margin: 65px auto 0;
186
- color: #bfbfbf;
187
- font-size: 10px;
188
- text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
189
- text-align: center;
190
- }
191
-
192
- .info p {
193
- line-height: 1;
194
- }
195
-
196
- .info a {
197
- color: inherit;
198
- text-decoration: none;
199
- font-weight: 400;
200
- }
201
-
202
- .info a:hover {
203
- text-decoration: underline;
204
- }
205
-
206
- @media screen and (-webkit-min-device-pixel-ratio: 0) {
207
- .toggle-all {
208
- background: none;
209
- }
210
- }
211
-
212
- @media (max-width: 430px) {
213
- .footer {
214
- height: 50px;
215
- }
216
-
217
- .filters {
218
- bottom: 10px;
219
- }
220
- }
221
-
222
- .todoapp {
223
- background: #fff;
224
- margin: 130px 0 40px 0;
225
- position: relative;
226
- box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
227
- }
228
-
229
- .todoapp input::-webkit-input-placeholder {
230
- font-style: italic;
231
- font-weight: 300;
232
- color: #e6e6e6;
233
- }
234
-
235
- .todoapp input::-moz-placeholder {
236
- font-style: italic;
237
- font-weight: 300;
238
- color: #e6e6e6;
239
- }
240
-
241
- .todoapp input::input-placeholder {
242
- font-style: italic;
243
- font-weight: 300;
244
- color: #e6e6e6;
245
- }
246
-
247
- .todoapp h1 {
248
- position: absolute;
249
- top: -155px;
250
- width: 100%;
251
- font-size: 100px;
252
- font-weight: 100;
253
- text-align: center;
254
- color: rgba(175, 47, 47, 0.15);
255
- -webkit-text-rendering: optimizeLegibility;
256
- -moz-text-rendering: optimizeLegibility;
257
- text-rendering: optimizeLegibility;
258
- }
259
- `)
260
-
261
- // var (
262
- // Container = Css(`
263
- // background: #fff;
264
- // margin: 130px 0 40px 0;
265
- // position: relative;
266
- // box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
267
- // `)
268
- // )
269
-
270
8
  type GetParams struct {
271
9
  Page int `json:"page"`
272
10
  Filter string `json:"filter"`
273
11
  }
274
12
 
275
- // func IndexLoader() {
276
- // Data: M{},
277
- // Meta: []*Meta{},
278
- // Links: []*Link{},
279
- // }
280
-
281
- func IndexAction(h Context, ctx context.Context, params PostParams) {
282
- }
283
-
284
- func IndexPage() {
285
- }
286
-
287
- // <meta name="description" content="{title}" />
288
- // <meta name="author" content="pyrossh" />
289
- // <meta name="keywords" content="pyros.sh, pyrossh, gromer" />
290
- // <meta />
291
-
292
13
  func GET(c Context, params GetParams) (*Node, int, error) {
293
- c.Meta("title", "Todos")
14
+ c.Meta("title", "Gromer Todos")
294
- c.Set("page", params.Page)
15
+ c.Meta("description", "Gromer Todos")
295
- c.Set("filter", params.Filter)
16
+ c.Meta("author", "gromer")
17
+ c.Meta("keywords", "gromer")
296
18
  return c.Render(`
297
- <section class="todoapp">
19
+ <div class="todoapp">
298
20
  <header class="header">
299
21
  <h1>todos</h1>
300
22
  <form hx-post="/" hx-target="#todo-list" hx-swap="afterbegin" _="on htmx:afterOnLoad set #text.value to ''">
301
23
  <input type="hidden" name="intent" value="create" />
302
- <input class="new-todo" id="text" name="text" placeholder="What needs to be done?" autofocus="false" autocomplete="off" /s>
24
+ <input class="new-todo" id="text" name="text" placeholder="What needs to be done?" autofocus="false" autocomplete="off" />
303
25
  </form>
304
26
  </header>
305
27
  <section class="main">
306
28
  <input class="toggle-all" id="toggle-all" type="checkbox" />
307
29
  <label for="toggle-all">Mark all as complete</label>
308
- <TodoList id="todo-list" page="{page}" filter="{filter}" />
30
+ <TodoList id="todo-list" page="{params.Page}" filter="{params.Filter}" />
309
31
  </section>
310
32
  <footer class="footer">
311
- <TodoCount filter="{filter}" />
33
+ <TodoCount filter="{params.Filter}" />
312
34
  <ul class="filters">
313
35
  <li>
314
36
  <a href="?filter=all">All</a>
@@ -325,6 +47,6 @@ func GET(c Context, params GetParams) (*Node, int, error) {
325
47
  <button type="submit" class="clear-completed" >Clear completed</button>
326
48
  </form>
327
49
  </footer>
328
- </section>
50
+ </div>
329
51
  `), 200, nil
330
52
  }
_example/routes/post.go CHANGED
@@ -32,8 +32,10 @@ func POST(c Context, params PostParams) (*Node, int, error) {
32
32
  }
33
33
  }
34
34
  return c.Render(`
35
+ <div>
35
- <TodoList id="todo-list" filter="all" page="1"></TodoList>
36
+ <TodoList id="todo-list" filter="all" page="1"></TodoList>
36
- <TodoCount filter="all" page="1"></TodoCount>
37
+ <TodoCount filter="all" page="1"></TodoCount>
38
+ </div>
37
39
  `), 200, nil
38
40
  } else if params.Intent == "create" {
39
41
  todo, err := todos.CreateTodo(c, params.Text)
@@ -42,8 +44,8 @@ func POST(c Context, params PostParams) (*Node, int, error) {
42
44
  }
43
45
  c.Set("todo", todo)
44
46
  return c.Render(`
45
- <Todo todo=todo></Todo>
47
+ <Todo />
46
- <TodoCount filter="all" page="1"></TodoCount>
48
+ <TodoCount filter="all" page="1" />
47
49
  `), 200, nil
48
50
  } else if params.Intent == "delete" {
49
51
  _, err := todos.DeleteTodo(c, params.ID)
@@ -65,7 +67,7 @@ func POST(c Context, params PostParams) (*Node, int, error) {
65
67
  }
66
68
  c.Set("todo", todo)
67
69
  return c.Render(`
68
- {{#Todo todo=todo}}{{/Todo}}
70
+ <Todo />
69
71
  `), 200, nil
70
72
  }
71
73
  return nil, 404, fmt.Errorf("Intent not specified: %s", params.Intent)
_example/routes/styles.go ADDED
@@ -0,0 +1,256 @@
1
+ package routes
2
+
3
+ import (
4
+ . "github.com/pyros2097/gromer/gsx"
5
+ )
6
+
7
+ var _ = Css(`
8
+ hr {
9
+ margin: 20px 0;
10
+ border: 0;
11
+ border-top: 1px dashed #c5c5c5;
12
+ border-bottom: 1px dashed #f7f7f7;
13
+ }
14
+
15
+ html,
16
+ body {
17
+ margin: 0;
18
+ padding: 0;
19
+ }
20
+
21
+ button {
22
+ margin: 0;
23
+ padding: 0;
24
+ border: 0;
25
+ background: none;
26
+ font-size: 100%;
27
+ vertical-align: baseline;
28
+ font-family: inherit;
29
+ font-weight: inherit;
30
+ color: inherit;
31
+ -webkit-appearance: none;
32
+ appearance: none;
33
+ -webkit-font-smoothing: antialiased;
34
+ -moz-osx-font-smoothing: grayscale;
35
+ }
36
+
37
+ body {
38
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
39
+ line-height: 1.4em;
40
+ background: #f5f5f5;
41
+ color: #4d4d4d;
42
+ min-width: 230px;
43
+ max-width: 550px;
44
+ margin: 0 auto;
45
+ -webkit-font-smoothing: antialiased;
46
+ -moz-osx-font-smoothing: grayscale;
47
+ font-weight: 300;
48
+ }
49
+
50
+ :focus {
51
+ outline: 0;
52
+ }
53
+
54
+ .hidden {
55
+ display: none;
56
+ }
57
+
58
+ .new-todo,
59
+ .edit {
60
+ position: relative;
61
+ margin: 0;
62
+ width: 100%;
63
+ font-size: 24px;
64
+ font-family: inherit;
65
+ font-weight: inherit;
66
+ line-height: 1.4em;
67
+ border: 0;
68
+ color: inherit;
69
+ padding: 6px;
70
+ border: 1px solid #999;
71
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
72
+ box-sizing: border-box;
73
+ -webkit-font-smoothing: antialiased;
74
+ -moz-osx-font-smoothing: grayscale;
75
+ }
76
+
77
+ .new-todo {
78
+ padding: 16px 16px 16px 60px;
79
+ border: none;
80
+ background: rgba(0, 0, 0, 0.003);
81
+ box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
82
+ }
83
+
84
+ .main {
85
+ position: relative;
86
+ z-index: 2;
87
+ border-top: 1px solid #e6e6e6;
88
+ }
89
+
90
+ .toggle-all {
91
+ text-align: center;
92
+ border: none; /* Mobile Safari */
93
+ opacity: 0;
94
+ position: absolute;
95
+ }
96
+
97
+ .toggle-all + label {
98
+ width: 60px;
99
+ height: 34px;
100
+ font-size: 0;
101
+ position: absolute;
102
+ top: -52px;
103
+ left: -13px;
104
+ -webkit-transform: rotate(90deg);
105
+ transform: rotate(90deg);
106
+ }
107
+
108
+ .toggle-all + label:before {
109
+ content: '❯';
110
+ font-size: 22px;
111
+ color: #e6e6e6;
112
+ padding: 10px 27px 10px 27px;
113
+ }
114
+
115
+ .toggle-all:checked + label:before {
116
+ color: #737373;
117
+ }
118
+
119
+ .footer {
120
+ color: #777;
121
+ padding: 10px 15px;
122
+ height: 20px;
123
+ text-align: center;
124
+ border-top: 1px solid #e6e6e6;
125
+ }
126
+
127
+ .footer:before {
128
+ content: '';
129
+ position: absolute;
130
+ right: 0;
131
+ bottom: 0;
132
+ left: 0;
133
+ height: 50px;
134
+ overflow: hidden;
135
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2);
136
+ }
137
+
138
+ .filters {
139
+ margin: 0;
140
+ padding: 0;
141
+ list-style: none;
142
+ position: absolute;
143
+ right: 0;
144
+ left: 0;
145
+ }
146
+
147
+ .filters li {
148
+ display: inline;
149
+ }
150
+
151
+ .filters li a {
152
+ color: inherit;
153
+ margin: 3px;
154
+ padding: 3px 7px;
155
+ text-decoration: none;
156
+ border: 1px solid transparent;
157
+ border-radius: 3px;
158
+ }
159
+
160
+ .filters li a:hover {
161
+ border-color: rgba(175, 47, 47, 0.1);
162
+ }
163
+
164
+ .filters li a.selected {
165
+ border-color: rgba(175, 47, 47, 0.2);
166
+ }
167
+
168
+ .clear-completed,
169
+ html .clear-completed:active {
170
+ float: right;
171
+ position: relative;
172
+ line-height: 20px;
173
+ text-decoration: none;
174
+ cursor: pointer;
175
+ }
176
+
177
+ .clear-completed:hover {
178
+ text-decoration: underline;
179
+ }
180
+
181
+ .info {
182
+ margin: 65px auto 0;
183
+ color: #bfbfbf;
184
+ font-size: 10px;
185
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
186
+ text-align: center;
187
+ }
188
+
189
+ .info p {
190
+ line-height: 1;
191
+ }
192
+
193
+ .info a {
194
+ color: inherit;
195
+ text-decoration: none;
196
+ font-weight: 400;
197
+ }
198
+
199
+ .info a:hover {
200
+ text-decoration: underline;
201
+ }
202
+
203
+ @media screen and (-webkit-min-device-pixel-ratio: 0) {
204
+ .toggle-all {
205
+ background: none;
206
+ }
207
+ }
208
+
209
+ @media (max-width: 430px) {
210
+ .footer {
211
+ height: 50px;
212
+ }
213
+
214
+ .filters {
215
+ bottom: 10px;
216
+ }
217
+ }
218
+
219
+ .todoapp {
220
+ background: #fff;
221
+ margin: 130px 0 40px 0;
222
+ position: relative;
223
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
224
+ }
225
+
226
+ .todoapp input::-webkit-input-placeholder {
227
+ font-style: italic;
228
+ font-weight: 300;
229
+ color: #e6e6e6;
230
+ }
231
+
232
+ .todoapp input::-moz-placeholder {
233
+ font-style: italic;
234
+ font-weight: 300;
235
+ color: #e6e6e6;
236
+ }
237
+
238
+ .todoapp input::input-placeholder {
239
+ font-style: italic;
240
+ font-weight: 300;
241
+ color: #e6e6e6;
242
+ }
243
+
244
+ .todoapp h1 {
245
+ position: absolute;
246
+ top: -155px;
247
+ width: 100%;
248
+ font-size: 100px;
249
+ font-weight: 100;
250
+ text-align: center;
251
+ color: rgba(175, 47, 47, 0.15);
252
+ -webkit-text-rendering: optimizeLegibility;
253
+ -moz-text-rendering: optimizeLegibility;
254
+ text-rendering: optimizeLegibility;
255
+ }
256
+ `)
go.mod CHANGED
@@ -3,9 +3,9 @@ module github.com/pyros2097/gromer
3
3
  go 1.18
4
4
 
5
5
  require (
6
- github.com/alecthomas/repr v0.1.0
7
6
  github.com/go-playground/validator/v10 v10.9.0
8
7
  github.com/gobuffalo/velvet v0.0.0-20170320144106-d97471bf5d8f
8
+ github.com/google/uuid v1.3.0
9
9
  github.com/gorilla/mux v1.8.0
10
10
  github.com/imdario/mergo v0.3.12
11
11
  github.com/rs/zerolog v1.26.1
@@ -30,7 +30,6 @@ require (
30
30
  github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
31
31
  github.com/golang/protobuf v1.5.2 // indirect
32
32
  github.com/google/go-cmp v0.5.6 // indirect
33
- github.com/google/uuid v1.3.0 // indirect
34
33
  github.com/google/wire v0.5.0 // indirect
35
34
  github.com/googleapis/gax-go/v2 v2.1.0 // indirect
36
35
  github.com/gorilla/css v1.0.0 // indirect
go.sum CHANGED
@@ -99,8 +99,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
99
99
  github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
100
100
  github.com/GoogleCloudPlatform/cloudsql-proxy v1.24.0/go.mod h1:3tx938GhY4FC+E1KT/jNjDw7Z5qxAEtIiERJ2sXjnII=
101
101
  github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
102
- github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
103
- github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
104
102
  github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
105
103
  github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
106
104
  github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
gsx/gsx.go CHANGED
@@ -8,9 +8,9 @@ import (
8
8
  "reflect"
9
9
  "regexp"
10
10
  "runtime"
11
+ "strconv"
11
12
  "strings"
12
13
 
13
- "github.com/alecthomas/repr"
14
14
  "golang.org/x/net/html"
15
15
  "golang.org/x/net/html/atom"
16
16
  )
@@ -41,18 +41,19 @@ type (
41
41
  }
42
42
  Context struct {
43
43
  context.Context
44
+ hxRequest bool
44
- data map[string]interface{}
45
+ data map[string]interface{}
45
- metas map[string]string
46
+ metas map[string]string
46
- links map[string]link
47
+ links map[string]link
47
- scripts map[string]bool
48
+ scripts map[string]bool
48
49
  }
49
50
  Node struct {
50
51
  html.Node
51
52
  }
52
53
  )
53
54
 
54
- func NewContext(c context.Context) Context {
55
+ func NewContext(c context.Context, hxRequest bool) Context {
55
- return Context{Context: c, data: map[string]interface{}{}, metas: map[string]string{}, links: map[string]link{}, scripts: map[string]bool{}}
56
+ return Context{Context: c, hxRequest: hxRequest, data: map[string]interface{}{}, metas: map[string]string{}, links: map[string]link{}, scripts: map[string]bool{}}
56
57
  }
57
58
 
58
59
  func (h Context) Get(k string) interface{} {
@@ -86,34 +87,38 @@ func (h Context) Render(tpl string) *Node {
86
87
  }
87
88
 
88
89
  func (n *Node) Write(ctx Context, w io.Writer) {
90
+ if !ctx.hxRequest {
89
- w.Write([]byte(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">`))
91
+ w.Write([]byte(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">`))
90
- w.Write([]byte(`<meta http-equiv="Content-Type" content="text/html;charset=utf-8"><meta content="utf-8" http-equiv="encoding">`))
92
+ w.Write([]byte(`<meta http-equiv="Content-Type" content="text/html;charset=utf-8"><meta content="utf-8" http-equiv="encoding">`))
91
- w.Write([]byte(`<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, viewport-fit=cover">`))
93
+ w.Write([]byte(`<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, viewport-fit=cover">`))
92
- for k, v := range ctx.metas {
94
+ for k, v := range ctx.metas {
93
- w.Write([]byte(fmt.Sprintf(`<meta name="%s" content="%s">`, k, v)))
95
+ w.Write([]byte(fmt.Sprintf(`<meta name="%s" content="%s">`, k, v)))
94
- }
95
- for k, v := range ctx.metas {
96
- if k == "title" {
97
- w.Write([]byte(fmt.Sprintf(`<title>%s</title>`, v)))
98
96
  }
97
+ for k, v := range ctx.metas {
98
+ if k == "title" {
99
+ w.Write([]byte(fmt.Sprintf(`<title>%s</title>`, v)))
99
- }
100
+ }
100
- for _, v := range ctx.links {
101
- if v.Type != "" || v.As != "" {
102
- w.Write([]byte(fmt.Sprintf(`<link rel="%s" href="%s" type="%s" as="%s">`, v.Rel, v.Href, v.Type, v.As)))
103
- } else {
104
- w.Write([]byte(fmt.Sprintf(`<link rel="%s" href="%s">`, v.Rel, v.Href)))
105
101
  }
102
+ for _, v := range ctx.links {
103
+ if v.Type != "" || v.As != "" {
104
+ w.Write([]byte(fmt.Sprintf(`<link rel="%s" href="%s" type="%s" as="%s">`, v.Rel, v.Href, v.Type, v.As)))
105
+ } else {
106
+ w.Write([]byte(fmt.Sprintf(`<link rel="%s" href="%s">`, v.Rel, v.Href)))
106
- }
107
+ }
108
+ }
107
- for src, sdefer := range ctx.scripts {
109
+ for src, sdefer := range ctx.scripts {
108
- if sdefer {
110
+ if sdefer {
109
- w.Write([]byte(fmt.Sprintf(`<script src="%s" defer="true"></script>`, src)))
111
+ w.Write([]byte(fmt.Sprintf(`<script src="%s" defer="true"></script>`, src)))
110
- } else {
112
+ } else {
111
- w.Write([]byte(fmt.Sprintf(`<script src="%s"></script>`, src)))
113
+ w.Write([]byte(fmt.Sprintf(`<script src="%s"></script>`, src)))
114
+ }
112
115
  }
116
+ w.Write([]byte(`</head><body>`))
113
117
  }
114
- w.Write([]byte(`</head><body>`))
115
118
  html.Render(w, &n.Node)
119
+ if !ctx.hxRequest {
116
- w.Write([]byte(`</body></html>`))
120
+ w.Write([]byte(`</body></html>`))
121
+ }
117
122
  }
118
123
 
119
124
  func (n *Node) String() string {
@@ -180,10 +185,13 @@ func convert(ref string, i interface{}) interface{} {
180
185
  } else {
181
186
  return iv
182
187
  }
188
+ case int:
189
+ return iv
183
190
  case string:
184
191
  return iv
192
+ default:
193
+ return iv
185
194
  }
186
- return nil
187
195
  }
188
196
 
189
197
  func getRefValue(ctx Context, ref string) interface{} {
@@ -193,8 +201,14 @@ func getRefValue(ctx Context, ref string) interface{} {
193
201
  parts := strings.Split(strings.ReplaceAll(ref, "!", ""), ".")
194
202
  if len(parts) == 2 {
195
203
  if v, ok := ctx.data[parts[0]]; ok {
204
+ a := reflect.ValueOf(v)
205
+ if a.Kind() == reflect.Ptr {
196
- i := reflect.ValueOf(v).Elem().FieldByName(parts[1]).Interface()
206
+ i := a.Elem().FieldByName(parts[1]).Interface()
197
- return convert(ref, i)
207
+ return convert(ref, i)
208
+ } else {
209
+ i := a.FieldByName(parts[1]).Interface()
210
+ return convert(ref, i)
211
+ }
198
212
  }
199
213
  }
200
214
  return convert(ref, ctx.data[ref])
@@ -288,7 +302,6 @@ func populate(ctx Context, n *html.Node) {
288
302
  }
289
303
  continue
290
304
  }
291
- repr.Println("AAAAAAA", n.Data)
292
305
  if n.FirstChild == nil {
293
306
  continue
294
307
  }
@@ -351,12 +364,23 @@ func populate(ctx Context, n *html.Node) {
351
364
 
352
365
  func renderComponent(ctx Context, comp ComponentFunc, n *html.Node) *Node {
353
366
  args := []reflect.Value{reflect.ValueOf(ctx)}
367
+ funcType := reflect.TypeOf(comp.Func)
354
- for _, arg := range comp.Args {
368
+ for i, arg := range comp.Args {
355
369
  if v, ok := ctx.data[arg]; ok {
356
370
  args = append(args, reflect.ValueOf(v))
357
371
  } else {
358
372
  v := getAttribute(arg, n.Attr)
373
+ t := funcType.In(i + 1)
374
+ switch t.Kind() {
375
+ case reflect.Int:
376
+ value, _ := strconv.Atoi(v)
377
+ args = append(args, reflect.ValueOf(value))
378
+ case reflect.Bool:
379
+ value, _ := strconv.ParseBool(v)
380
+ args = append(args, reflect.ValueOf(value))
381
+ default:
359
- args = append(args, reflect.ValueOf(v))
382
+ args = append(args, reflect.ValueOf(v))
383
+ }
360
384
  }
361
385
  }
362
386
  result := reflect.ValueOf(comp.Func).Call(args)
gsx/gsx_test.go CHANGED
@@ -15,12 +15,12 @@ type TodoData struct {
15
15
  func Todo(h Context, todo *TodoData) *Node {
16
16
  return h.Render(`
17
17
  <li id="todo-{todo.ID}" class="{ completed: todo.Completed }">
18
- <div class="view">
18
+ <div class="upper">
19
19
  <span>{todo.Text}</span>
20
20
  <span>{todo.Text}</span>
21
21
  </div>
22
22
  {children}
23
- <div class="count">
23
+ <div class="bottom">
24
24
  <span>{todo.Completed}</span>
25
25
  <span>{todo.Completed}</span>
26
26
  </div>
@@ -36,6 +36,14 @@ func TodoList(ctx Context, todos []*TodoData) (*Node, error) {
36
36
  `), nil
37
37
  }
38
38
 
39
+ func TodoCount(ctx Context, count int) (*Node, error) {
40
+ return ctx.Render(`
41
+ <span id="todo-count" class="todo-count" hx-swap-oob="true">
42
+ <strong>{count}</strong> item left
43
+ </span>
44
+ `), nil
45
+ }
46
+
39
47
  func WebsiteName() string {
40
48
  return "My Website"
41
49
  }
@@ -57,6 +65,7 @@ func TestComponent(t *testing.T) {
57
65
  <span>{todo.Completed}</span>
58
66
  </div>
59
67
  </Todo>
68
+ <Todo />
60
69
  </div>
61
70
  `).String()
62
71
  expected := stripWhitespace(`
@@ -177,21 +186,24 @@ func TestForComponent(t *testing.T) {
177
186
  r.Equal(expected, actual)
178
187
  }
179
188
 
180
- // func TestPage(t *testing.T) {
189
+ func TestMultipleComonent(t *testing.T) {
181
- // r := require.New(t)
190
+ r := require.New(t)
182
- // RegisterFunc(WebsiteName)
191
+ RegisterComponent(Todo, "todo")
192
+ RegisterComponent(TodoCount, "count")
183
- // h := Context{
193
+ h := Context{
184
- // data: map[string]interface{}{},
194
+ data: map[string]interface{}{
195
+ "todo": &TodoData{
196
+ ID: "3",
197
+ Text: "My third todo",
185
- // }
198
+ Completed: false,
199
+ },
200
+ },
201
+ }
186
- // actual := h.Render(`
202
+ actual := h.Render(`
187
- // <Page title="test">
188
- // <span>Hello</span>
203
+ <Todo />
189
- // </Page}>
204
+ <TodoCount />
190
- // `).String()
205
+ `).String()
191
- // expected := stripWhitespace(`
206
+ expected := stripWhitespace(`
192
- // <page title="test">
193
- // <meta charset="UTF-8"/>
194
- // </page>
195
- // `)
207
+ `)
196
- // r.Equal(expected, actual)
208
+ r.Equal(expected, actual)
197
- // }
209
+ }
http.go CHANGED
@@ -19,7 +19,6 @@ import (
19
19
  "sync"
20
20
  "time"
21
21
 
22
- "github.com/alecthomas/repr"
23
22
  "github.com/go-playground/validator/v10"
24
23
  "github.com/google/uuid"
25
24
  "github.com/gorilla/mux"
@@ -77,105 +76,6 @@ func RegisterAssets(fs embed.FS) {
77
76
  appAssets = fs
78
77
  }
79
78
 
80
- // func RegisterComponent(fn any, props ...string) {
81
- // name := getFunctionName(fn)
82
- // fnType := reflect.TypeOf(fn)
83
- // fnValue := reflect.ValueOf(fn)
84
- // handlebars.GlobalHelpers.Add(name, func(help handlebars.HelperContext) (template.HTML, error) {
85
- // args := []reflect.Value{}
86
- // var props any
87
- // if fnType.NumIn() > 0 {
88
- // structType := fnType.In(0)
89
- // instance := reflect.New(structType)
90
- // if structType.Kind() != reflect.Struct {
91
- // log.Fatal().Msgf("component '%s' props should be a struct", name)
92
- // }
93
- // rv := instance.Elem()
94
- // for i := 0; i < structType.NumField(); i++ {
95
- // if f := rv.Field(i); f.CanSet() {
96
- // jsonName := structType.Field(i).Tag.Get("json")
97
- // defaultValue := structType.Field(i).Tag.Get("default")
98
- // if jsonName == "children" {
99
- // s, err := help.Block()
100
- // if err != nil {
101
- // return "", err
102
- // }
103
- // f.Set(reflect.ValueOf(template.HTML(s)))
104
- // } else {
105
- // v := help.Context.Get(jsonName)
106
- // if v == nil {
107
- // f.Set(reflect.ValueOf(defaultValue))
108
- // } else {
109
- // f.Set(reflect.ValueOf(v))
110
- // }
111
- // }
112
- // }
113
- // }
114
- // args = append(args, rv)
115
- // props = rv.Interface()
116
- // }
117
- // res := fnValue.Call(args)
118
- // tpl := res[0].Interface().(*handlebars.Template)
119
- // tpl.Context.Set("props", props)
120
- // s, _, err := tpl.Render()
121
- // if err != nil {
122
- // return "", err
123
- // }
124
- // return template.HTML(s), nil
125
- // })
126
- // }
127
-
128
- // func RegisterContainer(fn any, props ...string) {
129
- // name := getFunctionName(fn)
130
- // fnType := reflect.TypeOf(fn)
131
- // fnValue := reflect.ValueOf(fn)
132
- // // shandlebars.GlobalHelpers.Add(name, func(help handlebars.HelperContext) (template.HTML, error) {
133
- // args := []reflect.Value{reflect.ValueOf(context.TODO())}
134
- // var props any
135
- // if fnType.NumIn() > 1 {
136
- // structType := fnType.In(1)
137
- // instance := reflect.New(structType)
138
- // if structType.Kind() != reflect.Struct {
139
- // log.Fatal().Msgf("component '%s' props should be a struct", name)
140
- // }
141
- // rv := instance.Elem()
142
- // for i := 0; i < structType.NumField(); i++ {
143
- // if f := rv.Field(i); f.CanSet() {
144
- // jsonName := structType.Field(i).Tag.Get("json")
145
- // defaultValue := structType.Field(i).Tag.Get("default")
146
- // if jsonName == "children" {
147
- // s, err := help.Block()
148
- // if err != nil {
149
- // return "", err
150
- // }
151
- // f.Set(reflect.ValueOf(template.HTML(s)))
152
- // } else {
153
- // v := help.Context.Get(jsonName)
154
- // if v == nil {
155
- // f.Set(reflect.ValueOf(defaultValue))
156
- // } else {
157
- // f.Set(reflect.ValueOf(v))
158
- // }
159
- // }
160
- // }
161
- // }
162
- // args = append(args, rv)
163
- // props = rv.Interface()
164
- // }
165
- // res := fnValue.Call(args)
166
- // tpl := res[0].Interface().(*handlebars.Template)
167
- // // if res[1].Interface() != nil {
168
- // // show error in component
169
- // // }
170
- // tpl.Context.Set("props", props)
171
- // s, _, err := tpl.Render()
172
- // if err != nil {
173
- // return "", err
174
- // }
175
- // return template.HTML(s), nil
176
- // })
177
- // }
178
-
179
79
  func RespondError(w http.ResponseWriter, status int, err error) {
180
80
  w.Header().Set("Content-Type", "application/json")
181
81
  w.WriteHeader(status) // always write status last
@@ -227,7 +127,7 @@ func addRouteDef(method, route string, h interface{}) {
227
127
 
228
128
  func PerformRequest(route string, h interface{}, ctx context.Context, w http.ResponseWriter, r *http.Request) {
229
129
  params := GetRouteParams(route)
230
- renderContext := gsx.NewContext(ctx)
130
+ renderContext := gsx.NewContext(ctx, r.Header.Get("HX-Request") == "true")
231
131
  renderContext.Set("requestId", uuid.NewString())
232
132
  renderContext.Link("rel", GetAssetUrl("images/icon.png"), "", "")
233
133
  renderContext.Link("stylesheet", GetStylesUrl(), "", "")
@@ -240,7 +140,6 @@ func PerformRequest(route string, h interface{}, ctx context.Context, w http.Res
240
140
  for _, k := range params {
241
141
  args = append(args, reflect.ValueOf(vars[k]))
242
142
  }
243
- repr.Println(len(args), icount)
244
143
  if len(args) != icount {
245
144
  structType := funcType.In(icount - 1)
246
145
  instance := reflect.New(structType)
@@ -308,6 +207,7 @@ func PerformRequest(route string, h interface{}, ctx context.Context, w http.Res
308
207
  RespondError(w, 400, fmt.Errorf("Illegal Content-Type tag found %s", contentType))
309
208
  return
310
209
  }
210
+ renderContext.Set("params", instance.Elem().Interface())
311
211
  args = append(args, instance.Elem())
312
212
  }
313
213
  values := reflect.ValueOf(h).Call(args)
@@ -387,6 +287,7 @@ func (w *LogResponseWriter) LogRequest(r *http.Request) {
387
287
  logger := log.WithLevel(zerolog.InfoLevel)
388
288
  if w.err != nil {
389
289
  stack := string(debug.Stack())
290
+ println(stack)
390
291
  logger = log.WithLevel(zerolog.ErrorLevel).Err(w.err).Str("stack", stack).Stack()
391
292
  }
392
293
  ua := useragent.Parse(r.UserAgent())
@@ -451,7 +352,8 @@ func CacheMiddleware(next http.Handler) http.Handler {
451
352
  func StatusHandler(h interface{}) http.Handler {
452
353
  return LogMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
453
354
  ctx := context.WithValue(context.WithValue(r.Context(), "url", r.URL), "header", r.Header)
355
+ renderContext := gsx.NewContext(ctx, r.Header.Get("HX-Request") == "true")
454
- values := reflect.ValueOf(h).Call([]reflect.Value{reflect.ValueOf(map[string]interface{}{}), reflect.ValueOf(ctx)})
356
+ values := reflect.ValueOf(h).Call([]reflect.Value{reflect.ValueOf(renderContext)})
455
357
  response := values[0].Interface()
456
358
  responseStatus := values[1].Interface().(int)
457
359
  responseError := values[2].Interface()
@@ -463,7 +365,7 @@ func StatusHandler(h interface{}) http.Handler {
463
365
 
464
366
  // This has to be at end always after headers are set
465
367
  w.WriteHeader(responseStatus)
466
- response.(*gsx.Node).Write(gsx.NewContext(r.Context()), w)
368
+ response.(*gsx.Node).Write(renderContext, w)
467
369
  })).(http.Handler)
468
370
  }
469
371