Browse Source

Extremely basic initial commit.

master
Syfaro 1 year ago
commit
94769f6828

+ 5
- 0
.gitignore View File

@@ -0,0 +1,5 @@
1
+*.sqlite
2
+*.phar
3
+*.lock
4
+vendor/
5
+.idea/

+ 7
- 0
composer.json View File

@@ -0,0 +1,7 @@
1
+{
2
+    "require": {
3
+        "klein/klein": "v2.1.2",
4
+        "j4mie/paris": "v1.5.6",
5
+        "twig/twig": "2.x-dev"
6
+    }
7
+}

+ 425
- 0
index.php View File

@@ -0,0 +1,425 @@
1
+<?php
2
+
3
+require __DIR__ . '/vendor/autoload.php';
4
+
5
+ORM::configure("sqlite:" . __DIR__ . "/db.sqlite");
6
+
7
+/**
8
+ * Class User
9
+ * An administrative user.
10
+ * @property int $id
11
+ * @property string $username
12
+ * @property string $password
13
+ */
14
+class User extends Model {
15
+    public static function hash_password($password) {
16
+        return password_hash($password, PASSWORD_BCRYPT);
17
+    }
18
+
19
+    public function verify_password($password) {
20
+        return password_verify($password, $this->password);
21
+    }
22
+}
23
+
24
+/**
25
+ * Class Question
26
+ * @property int $id
27
+ * @property string $title
28
+ * @property string $description
29
+ * @property string $type
30
+ */
31
+class Question extends Model {
32
+    public function configs() {
33
+        return $this->has_many('QuestionConfig');
34
+    }
35
+
36
+    public function config($key) {
37
+        return QuestionConfig::where('key', $key)->find_one();
38
+    }
39
+
40
+    public function responses() {
41
+        switch ($this->type) {
42
+            case 'freeform':
43
+                return FreeFormResponse::where('question_id', $this->id);
44
+            case 'numeric':
45
+                return NumericResponse::where('question_id', $this->id);
46
+            case 'single':
47
+                return SingleChoiceResponse::where('question_id', $this->id);
48
+        }
49
+
50
+        return null;
51
+    }
52
+
53
+    public function response($response_id) {
54
+        switch ($this->type) {
55
+            case 'freeform':
56
+                return FreeFormResponse::where('id', $response_id);
57
+            case 'numeric':
58
+                return NumericResponse::where('id', $response_id);
59
+            case 'single':
60
+                return SingleChoiceResponse::where('id', $response_id);
61
+        }
62
+
63
+        return null;
64
+    }
65
+
66
+    public function choices() {
67
+        if ($this->type == 'freeform' || $this->type == 'numeric') return null;
68
+
69
+        return $this->has_many('ResponseChoice');
70
+    }
71
+
72
+    public function choice($choice_id) {
73
+        if ($this->type == 'freeform' || $this->type == 'numeric') return null;
74
+
75
+        return $this->has_one('ResponseChoice');
76
+    }
77
+}
78
+
79
+/**
80
+ * Class QuestionConfig
81
+ * @property int $id
82
+ * @property int $question_id
83
+ * @property string $key
84
+ * @property string $value
85
+ */
86
+class QuestionConfig extends Model {
87
+    public function question() {
88
+        return $this->belongs_to('Question');
89
+    }
90
+}
91
+
92
+/**
93
+ * Class Choice
94
+ * @property int $id
95
+ * @property int $question_id
96
+ * @property string $value
97
+ */
98
+class ResponseChoice extends Model {
99
+    public function question() {
100
+        return $this->belongs_to('Question');
101
+    }
102
+}
103
+
104
+/**
105
+ * Class Response
106
+ * A generic response type extended by more specific types.
107
+ */
108
+class Response extends Model {
109
+    public function question() {
110
+        return $this->belongs_to('Question');
111
+    }
112
+}
113
+
114
+/**
115
+ * Class FreeFormResponse
116
+ * @property int $id
117
+ * @property int $question_id
118
+ * @property string $value
119
+ */
120
+class FreeFormResponse extends Response {
121
+}
122
+
123
+/**
124
+ * Class NumericResponse
125
+ * @property int $id
126
+ * @property int $question_id
127
+ * @property int $value
128
+ */
129
+class NumericResponse extends Response {
130
+}
131
+
132
+/**
133
+ * Class SingleChoiceResponse
134
+ * @property int $id
135
+ * @property int $question_id
136
+ * @property int $choice_id
137
+ */
138
+class SingleChoiceResponse extends Response {
139
+    public function choice() {
140
+        return ResponseChoice::where('id', $this->choice_id);
141
+    }
142
+}
143
+
144
+$klein = new \Klein\Klein();
145
+
146
+$klein->respond(function ($request, $response, $service, $app) {
147
+    $service->startSession();
148
+
149
+    $app->register('user', function () {
150
+        $user_id = @$_SESSION['USER_ID'];
151
+        if (!$user_id) return false;
152
+
153
+        return User::find_one($user_id);
154
+    });
155
+
156
+    $app->register('twig', function () {
157
+        $loader = new Twig_Loader_Filesystem(__DIR__ . '/templates');
158
+        $twig = new Twig_Environment($loader);
159
+
160
+        return $twig;
161
+    });
162
+});
163
+
164
+$klein->respond('GET', '/', function ($request, $response, $service, $app) {
165
+    return $app->twig->render('index.html');
166
+});
167
+
168
+$klein->with('/admin', function () use ($klein) {
169
+    $klein->respond('GET', '', function ($request, $response, $service, $app) {
170
+        if (!$app->user) return $response->redirect('/admin/login');
171
+
172
+        $questions = Question::find_many();
173
+
174
+        return $app->twig->render('admin/index.html', [
175
+            'questions' => $questions,
176
+        ]);
177
+    });
178
+
179
+    $klein->respond('GET', '/setup', function ($request, $response, $service, $app) {
180
+        if (User::find_one()) {
181
+            return $response->redirect('/admin');
182
+        }
183
+
184
+        return $app->twig->render('admin/setup.html');
185
+    });
186
+
187
+    $klein->respond('POST', '/setup', function ($request, $response, $service, $app) {
188
+        if (User::find_one()) {
189
+            return $response->redirect('/admin');
190
+        }
191
+
192
+        $service->validateParam('username')->isLen(3, 24)->isChars('a-zA-Z0-9-');
193
+        $service->validateParam('password')->isLen(10, 128);
194
+
195
+        $user = User::create();
196
+        $user->username = $request->username;
197
+        $user->password = User::hash_password($request->password);
198
+        $user->save();
199
+
200
+        $_SESSION['USER_ID'] = $user->id;
201
+
202
+        return $response->redirect('/admin');
203
+    });
204
+
205
+    $klein->respond('GET', '/login', function ($request, $response, $service, $app) {
206
+        return $app->twig->render('admin/login.html');
207
+    });
208
+
209
+    $klein->respond('POST', '/login', function ($request, $response, $service, $app) {
210
+        $user = User::where('username', $request->username)->find_one();
211
+        if (!$user) return $response->redirect('/admin/login');
212
+        if (!$user->verify_password($request->password)) return $response->redirect('/admin/login');
213
+
214
+        $_SESSION['USER_ID'] = $user->id;
215
+
216
+        return $response->redirect('/admin');
217
+    });
218
+
219
+    $klein->respond('GET', '/logout', function ($request, $response, $service, $app) {
220
+        unset($_SESSION['USER_ID']);
221
+        return $response->redirect('/admin');
222
+    });
223
+
224
+    $klein->with('/question', function () use ($klein) {
225
+        $klein->with('/[i:id]', function () use ($klein) {
226
+            $klein->respond('GET', '', function ($request, $response, $service, $app) {
227
+                if (!$app->user) return $response->redirect('/admin/login');
228
+
229
+                $question = Question::find_one($request->id);
230
+
231
+                return $app->twig->render('admin/question/view.html', [
232
+                    'question' => $question,
233
+                ]);
234
+            });
235
+
236
+            $klein->respond('POST', '', function ($request, $response, $service, $app) {
237
+                if (!$app->user) return $response->redirect('/admin/login');
238
+
239
+                $question = Question::find_one($request->id);
240
+
241
+                $question->title = $request->title;
242
+                $question->description = $request->description;
243
+
244
+                $question->save();
245
+
246
+                return $response->redirect('/admin');
247
+            });
248
+
249
+            $klein->respond('POST', '/delete', function ($request, $response, $service, $app) {
250
+                if (!$app->user) return $response->redirect('/admin/login');
251
+
252
+                $question = Question::find_one($request->id);
253
+
254
+                $question->delete();
255
+
256
+                return $response->redirect('/admin');
257
+            });
258
+
259
+            $klein->with('/choice', function () use ($klein) {
260
+                $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
261
+                    $choice = ResponseChoice::create();
262
+
263
+                    $choice->question_id = $request->id;
264
+                    $choice->value = $request->value;
265
+
266
+                    $choice->save();
267
+
268
+                    return $response->redirect('/admin/question/' . $request->id);
269
+                });
270
+
271
+                $klein->with('/[i:cid]', function () use ($klein) {
272
+                    $klein->respond('GET', '', function ($request, $response, $service, $app) {
273
+                        $choice = ResponseChoice::find_one($request->cid);
274
+
275
+                        return $app->twig->render('admin/question/choice/view.html', [
276
+                            'choice' => $choice,
277
+                        ]);
278
+                    });
279
+
280
+                    $klein->respond('POST', '/delete', function ($request, $response, $service, $app) {
281
+                        $choice = ResponseChoice::find_one($request->cid);
282
+                        $choice->delete();
283
+
284
+                        return $response->redirect('/admin/question/' . $request->id);
285
+                    });
286
+                });
287
+            });
288
+
289
+            $klein->with('/response', function () use ($klein) {
290
+                $klein->with('/[i:rid]', function () use ($klein) {
291
+                    $klein->respond('GET', '', function ($request, $response, $service, $app) {
292
+                        if (!$app->user) return $response->redirect('/admin/login');
293
+
294
+                        $question = Question::find_one($request->id);
295
+                        $resp = $question->response($request->rid);
296
+
297
+                        return $app->twig->render('admin/question/response/view.html', [
298
+                            'question' => $question,
299
+                            'response' => $resp,
300
+                        ]);
301
+                    });
302
+
303
+                    $klein->respond('POST', '/delete', function ($request, $response, $service, $app) {
304
+                        $question = Question::find_one($request->id);
305
+                        $resp = $question->response($request->rid)->find_one();
306
+                        $resp->delete();
307
+
308
+                        return $response->redirect('/admin/question/' . $question->id);
309
+                    });
310
+                });
311
+
312
+                $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
313
+                    $service->validateParam('value')->notNull();
314
+
315
+                    if (!$app->user) return $response->redirect('/admin/login');
316
+
317
+                    $question = Question::find_one($request->id);
318
+
319
+                    switch ($question->type) {
320
+                        case 'freeform':
321
+                            $freeform = FreeFormResponse::create();
322
+                            $freeform->question_id = $question->id;
323
+                            $freeform->value = $request->value;
324
+                            $freeform->save();
325
+                            break;
326
+                        case 'numeric':
327
+                            $numeric = NumericResponse::create();
328
+                            $service->validateParam('value')->isInt();
329
+                            $numeric->question_id = $question->id;
330
+                            $numeric->value = $request->value;
331
+                            $numeric->save();
332
+                            break;
333
+                        case 'single':
334
+                            $single = SingleChoiceResponse::create();
335
+                            $single->question_id = $question->id;
336
+                            $single->choice_id = $request->value;
337
+                            $single->save();
338
+                            break;
339
+                        default:
340
+                            return 'invalid type';
341
+                    }
342
+
343
+                    return $response->redirect('/admin/question/' . $question->id);
344
+                });
345
+            });
346
+        });
347
+
348
+        $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
349
+            if (!$app->user) return $response->redirect('/admin/login');
350
+
351
+            $question = Question::create();
352
+
353
+            $question->title = $request->title;
354
+            $question->description = $request->description;
355
+            $question->type = $request->type;
356
+
357
+            $question->save();
358
+
359
+            return $response->redirect('/admin');
360
+        });
361
+    });
362
+});
363
+
364
+$klein->with('/api/v1', function () use ($klein) {
365
+    $klein->with('/question/[i:id]', function () use ($klein) {
366
+        $klein->respond('GET', '', function ($request, $response, $service, $app) {
367
+            $question = Question::find_one($request->id);
368
+
369
+            $data = $question->as_array();
370
+
371
+            if ($question->type == 'single') {
372
+                $data['choices'] = $question->choices()->find_array();
373
+            }
374
+
375
+            return $response->json($data);
376
+        });
377
+
378
+        $klein->with('/response', function () use ($klein) {
379
+            $klein->respond('GET', '', function ($request, $response, $service, $app) {
380
+                $question = Question::find_one($request->id);
381
+
382
+                switch ($question->type) {
383
+                    case 'freeform':
384
+                    case 'numeric':
385
+                        return $response->json($question->responses()->find_array());
386
+                    case 'single':
387
+                        $items = $question->responses()->find_many();
388
+                        $responses = [];
389
+                        foreach ($items as $item) {
390
+                            $data = $item->as_array();
391
+                            $data['choice'] = ResponseChoice::find_one($item->choice_id)->value;
392
+                            $responses[] = $data;
393
+                        }
394
+                        return $response->json($responses);
395
+                    default:
396
+                        return 'invalid type';
397
+                }
398
+            });
399
+
400
+            $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
401
+                $json = json_decode($request->body());
402
+                $question = Question::find_one($request->id);
403
+
404
+                switch ($question->type) {
405
+                    case 'freeform':
406
+                        $freeform = FreeFormResponse::create();
407
+                        $freeform->question_id = $question->id;
408
+                        $freeform->value = $json->value;
409
+                        $freeform->save();
410
+                        break;
411
+                    default:
412
+                        return 'invalid type';
413
+                }
414
+
415
+                return 'Added.';
416
+            });
417
+        });
418
+    });
419
+});
420
+
421
+$klein->respond('GET', '/peppershrike.js', function ($request, $response) {
422
+    return $response->file(__DIR__ . '/peppershrike.js');
423
+});
424
+
425
+$klein->dispatch();

+ 96
- 0
peppershrike.js View File

@@ -0,0 +1,96 @@
1
+const HOST = 'http://localhost:80/api/v1';
2
+let DONE_MESSAGE = 'Thank you!';
3
+
4
+class Peppershrike {
5
+    constructor(id, container) {
6
+        this.id = id;
7
+        this.container = container;
8
+
9
+        this.init();
10
+    }
11
+
12
+    async init() {
13
+        this.question = await Peppershrike.getQuestion(this.id);
14
+        this.buildFreeFormQuestion(this.question);
15
+        this.container.appendChild(this.form);
16
+    }
17
+
18
+    async handleFormSubmit (ev) {
19
+        ev.preventDefault();
20
+
21
+        this.submit.disabled = true;
22
+        this.inputs.forEach(input => input.disabled = true);
23
+
24
+        let data = {};
25
+
26
+        this.inputs.forEach(input => {
27
+            data[input.dataset.name] = input.value;
28
+        });
29
+
30
+        await fetch(ev.target.action, {
31
+            body: JSON.stringify(data),
32
+            headers: {
33
+                'Content-Type': 'application/json',
34
+            },
35
+            method: 'POST',
36
+        });
37
+
38
+        this.form.style.display = 'none';
39
+
40
+        this.form.parentNode.innerHTML = DONE_MESSAGE;
41
+    }
42
+
43
+    buildFreeFormQuestion () {
44
+        const form = document.createElement('form');
45
+        this.form = form;
46
+
47
+        form.dataset.type = this.question.type;
48
+
49
+        form.method = 'POST';
50
+        form.action = `${HOST}/question/${this.question.id}/response/add`;
51
+
52
+        const input = document.createElement('input');
53
+        input.dataset.name = 'value';
54
+        input.placeholder = 'Your response';
55
+        input.classList.add('peppershrike-input', 'peppershrike-freeform-response');
56
+
57
+        this.inputs = [input];
58
+
59
+        form.appendChild(input);
60
+
61
+        const submit = document.createElement('button');
62
+        this.submit = submit;
63
+        submit.innerHTML = 'Submit';
64
+        submit.disabled = true;
65
+        submit.classList.add('peppershrike-submit');
66
+
67
+        form.appendChild(submit);
68
+
69
+        input.addEventListener('keyup', ev => {
70
+            submit.disabled = input.value === '';
71
+        });
72
+
73
+        form.addEventListener('submit', this.handleFormSubmit.bind(this));
74
+    }
75
+
76
+
77
+    static async getQuestion (id) {
78
+        const resp = await fetch(`${HOST}/question/${id}`);
79
+        return await resp.json();
80
+    }
81
+
82
+    static async getResponses (id) {
83
+        const resp = await fetch(`${HOST}/question/${id}/response`);
84
+        return await resp.json();
85
+    }
86
+
87
+    static work() {
88
+        const questions = Array.from(document.querySelectorAll('.peppershrike[data-id]'));
89
+
90
+        questions.forEach(async question => {
91
+            new Peppershrike(question.dataset.id, question);
92
+        })
93
+    }
94
+}
95
+
96
+Peppershrike.work();

+ 32
- 0
templates/admin/index.html View File

@@ -0,0 +1,32 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike admin</title>
8
+</head>
9
+<body>
10
+<h1>peppershrike admin</h1>
11
+<a href="/admin/logout">Logout</a>
12
+
13
+<ul>
14
+{% for question in questions %}
15
+    <li><a href="/admin/question/{{ question.id }}">{{ question.title }}</a></li>
16
+{% endfor %}
17
+</ul>
18
+
19
+<h2>Add question</h2>
20
+<form method="POST" action="/admin/question/add">
21
+    <input type="text" name="title" placeholder="Title"><br>
22
+    <input type="text" name="description" placeholder="Description"><br>
23
+    <select name="type">
24
+        <option value="freeform">Free Form</option>
25
+        <option value="numeric">Numeric</option>
26
+        <option value="single">Single Choice</option>
27
+        <option value="multiple">Multiple Choice</option>
28
+    </select><br>
29
+    <button>Add question</button>
30
+</form>
31
+</body>
32
+</html>

+ 18
- 0
templates/admin/login.html View File

@@ -0,0 +1,18 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike login</title>
8
+</head>
9
+<body>
10
+<h1>peppershrike login</h1>
11
+
12
+<form method="POST">
13
+    <input type="text" placeholder="Username" name="username"><br>
14
+    <input type="password" placeholder="Password" name="password"><br>
15
+    <button>Login</button>
16
+</form>
17
+</body>
18
+</html>

+ 16
- 0
templates/admin/question/choice/view.html View File

@@ -0,0 +1,16 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike choice</title>
8
+</head>
9
+<body>
10
+<h1>{{ choice.value }}</h1>
11
+
12
+<form method="POST" action="/admin/question/{{ choice.question.find_one.id }}/choice/{{ choice.id }}/delete">
13
+    <button>Delete</button>
14
+</form>
15
+</body>
16
+</html>

+ 20
- 0
templates/admin/question/response/view.html View File

@@ -0,0 +1,20 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike response</title>
8
+</head>
9
+<body>
10
+{% if question.type == 'freeform' or question.type == 'numeric' %}
11
+<h1>{{ response.find_one.value }}</h1>
12
+{% else %}
13
+<h1>{{ response.find_one.choice.find_one.value }}</h1>
14
+{% endif %}
15
+
16
+<form method="POST" action="/admin/question/{{ question.id }}/response/{{ response.find_one.id }}/delete">
17
+    <button>Delete</button>
18
+</form>
19
+</body>
20
+</html>

+ 67
- 0
templates/admin/question/view.html View File

@@ -0,0 +1,67 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike question</title>
8
+</head>
9
+<body>
10
+<h1>{{ question.title }}</h1>
11
+
12
+<form method="POST" action="/admin/question/{{ question.id }}/delete">
13
+    <button>Delete</button>
14
+</form>
15
+
16
+<form method="POST" action="/admin/question/{{ question.id }}">
17
+    <input type="text" name="title" placeholder="Title" value="{{ question.title }}"><br>
18
+    <input type="text" name="description" placeholder="Description" value="{{ question.description }}"><br>
19
+    <button>Update question</button>
20
+</form>
21
+
22
+{% if question.type == 'single' or question.type == 'multiple' %}
23
+<h2>Choices</h2>
24
+
25
+<ul>
26
+{% for choice in question.choices.find_many %}
27
+    <li><a href="/admin/question/{{ question.id }}/choice/{{ choice.id }}">{{ choice.value }}</a></li>
28
+{% endfor %}
29
+</ul>
30
+
31
+<form method="POST" action="/admin/question/{{ question.id }}/choice/add">
32
+    <input type="text" name="value" placeholder="Choice"><br>
33
+    <button>Add choice</button>
34
+</form>
35
+{% endif %}
36
+
37
+<h2>Responses</h2>
38
+
39
+{% if question.type == 'freeform' or question.type == 'numeric' %}
40
+<ul>
41
+{% for response in question.responses.find_many %}
42
+    <li><a href="/admin/question/{{ question.id }}/response/{{ response.id }}">{{ response.value }}</a></li>
43
+{% endfor %}
44
+</ul>
45
+
46
+<form method="POST" action="/admin/question/{{ question.id }}/response/add">
47
+    <input type="text" name="value" placeholder="Response"><br>
48
+    <button>Add response</button>
49
+</form>
50
+{% else %}
51
+<ul>
52
+    {% for response in question.responses.find_many %}
53
+    <li><a href="/admin/question/{{ question.id }}/response/{{ response.id }}">{{ response.choice.find_one.value }}</a></li>
54
+    {% endfor %}
55
+</ul>
56
+
57
+<form method="POST" action="/admin/question/{{ question.id }}/response/add">
58
+    <select name="value">
59
+    {% for choice in question.choices.find_many %}
60
+        <option value="{{ choice.id }}">{{ choice.value }}</option>
61
+    {% endfor %}
62
+    </select>
63
+    <button>Add response</button>
64
+</form>
65
+{% endif %}
66
+</body>
67
+</html>

+ 18
- 0
templates/admin/setup.html View File

@@ -0,0 +1,18 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike setup</title>
8
+</head>
9
+<body>
10
+<h1>peppershrike setup</h1>
11
+
12
+<form method="POST">
13
+    <input type="text" placeholder="Username" name="username"><br>
14
+    <input type="password" placeholder="Password" name="password"><br>
15
+    <button>Create account</button>
16
+</form>
17
+</body>
18
+</html>

+ 17
- 0
templates/index.html View File

@@ -0,0 +1,17 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+    <meta charset="utf-8">
5
+    <meta name="viewport" content="width=device-width">
6
+
7
+    <title>peppershrike</title>
8
+</head>
9
+<body>
10
+<h1>peppershrike</h1>
11
+
12
+<h2>example:</h2>
13
+<div class="peppershrike" data-id="1"></div>
14
+
15
+<script src="peppershrike.js"></script>
16
+</body>
17
+</html>

Loading…
Cancel
Save