Compare commits
9 Commits
3.4
...
9421bb2035
| Author | SHA1 | Date | |
|---|---|---|---|
| 9421bb2035 | |||
| d6e8cf66cc | |||
| 2369827033 | |||
| 3ebb03d470 | |||
| b759c3494e | |||
| 0b7fa77247 | |||
| 4ea55d4d25 | |||
| 4aa6e832c7 | |||
| 41853a7645 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -256,3 +256,7 @@ paket-files/
|
|||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
src/environment.txt
|
src/environment.txt
|
||||||
|
|
||||||
|
# PHP ignore files
|
||||||
|
src/vendor
|
||||||
|
src/.env
|
||||||
|
|||||||
@@ -8,9 +8,4 @@ myPrayerJournal was borne of out of a personal desire [Daniel](https://github.co
|
|||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
The documentation for the site is at <https://bit-badger.github.io/myPrayerJournal/>.
|
The documentation for the site is at <https://prayerjournal.me/docs>.
|
||||||
|
|
||||||
---
|
|
||||||
_Thanks to [JetBrains](https://jb.gg/OpenSource) for licensing their awesome toolset to this project._
|
|
||||||
|
|
||||||
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo (Main) logo" width="100" height="100">](https://jb.gg/OpenSource)
|
|
||||||
|
|||||||
9
src/Caddyfile
Normal file
9
src/Caddyfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
frankenphp
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
http://localhost:3000 {
|
||||||
|
root * ./public
|
||||||
|
try_files {path} {path}.php
|
||||||
|
php_server
|
||||||
|
}
|
||||||
@@ -526,45 +526,45 @@ let routes = [
|
|||||||
GET_HEAD [ route "/" Home.home ]
|
GET_HEAD [ route "/" Home.home ]
|
||||||
subRoute "/components/" [
|
subRoute "/components/" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
route "journal-items" Components.journalItems
|
route "journal-items" Components.journalItems // done
|
||||||
routef "request/%s/add-notes" Components.addNotes
|
routef "request/%s/add-notes" Components.addNotes // done
|
||||||
routef "request/%s/item" Components.requestItem
|
routef "request/%s/item" Components.requestItem
|
||||||
routef "request/%s/notes" Components.notes
|
routef "request/%s/notes" Components.notes // done
|
||||||
routef "request/%s/snooze" Components.snooze
|
routef "request/%s/snooze" Components.snooze // done
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/docs" Home.docs ]
|
GET_HEAD [ route "/docs" Home.docs ] // done
|
||||||
GET_HEAD [ route "/journal" Journal.journal ]
|
GET_HEAD [ route "/journal" Journal.journal ] // done
|
||||||
subRoute "/legal/" [
|
subRoute "/legal/" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
route "privacy-policy" Legal.privacyPolicy
|
route "privacy-policy" Legal.privacyPolicy // done
|
||||||
route "terms-of-service" Legal.termsOfService
|
route "terms-of-service" Legal.termsOfService // done
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
subRoute "/request" [
|
subRoute "/request" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/%s/edit" Request.edit
|
routef "/%s/edit" Request.edit // done
|
||||||
routef "/%s/full" Request.getFull
|
routef "/%s/full" Request.getFull // done
|
||||||
route "s/active" Request.active
|
route "s/active" Request.active // done
|
||||||
route "s/answered" Request.answered
|
route "s/answered" Request.answered // done
|
||||||
route "s/snoozed" Request.snoozed
|
route "s/snoozed" Request.snoozed // done
|
||||||
]
|
]
|
||||||
PATCH [
|
PATCH [
|
||||||
route "" Request.update
|
route "" Request.update // done
|
||||||
routef "/%s/cancel-snooze" Request.cancelSnooze
|
routef "/%s/cancel-snooze" Request.cancelSnooze
|
||||||
routef "/%s/prayed" Request.prayed
|
routef "/%s/prayed" Request.prayed // done
|
||||||
routef "/%s/show" Request.show
|
routef "/%s/show" Request.show
|
||||||
routef "/%s/snooze" Request.snooze
|
routef "/%s/snooze" Request.snooze // done
|
||||||
]
|
]
|
||||||
POST [
|
POST [
|
||||||
route "" Request.add
|
route "" Request.add // done
|
||||||
routef "/%s/note" Request.addNote
|
routef "/%s/note" Request.addNote // done
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
subRoute "/user/" [
|
subRoute "/user/" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
route "log-off" User.logOff
|
route "log-off" User.logOff // done
|
||||||
route "log-on" User.logOn
|
route "log-on" User.logOn // done
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
26
src/composer.json
Normal file
26
src/composer.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "bit-badger/my-prayer-journal",
|
||||||
|
"minimum-stability": "beta",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-sqlite3": "*",
|
||||||
|
"bit-badger/pdo-document": "^1",
|
||||||
|
"visus/cuid2": "^4",
|
||||||
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
|
"guzzlehttp/psr7": "^2.6",
|
||||||
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
|
"auth0/auth0-php": "^8.11",
|
||||||
|
"vlucas/phpdotenv": "^5.6"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"MyPrayerJournal\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2403
src/composer.lock
generated
Normal file
2403
src/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
src/convert-from-v3.php
Normal file
56
src/convert-from-v3.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use BitBadger\PDODocument\{Configuration, Custom, Definition, Document, Mode};
|
||||||
|
use BitBadger\PDODocument\Mapper\ArrayMapper;
|
||||||
|
use MyPrayerJournal\{History, Note, Recurrence, RecurrencePeriod, Request, RequestAction, Table};
|
||||||
|
|
||||||
|
require 'start.php';
|
||||||
|
|
||||||
|
echo 'Retrieving v3 requests...' . PHP_EOL;
|
||||||
|
|
||||||
|
Configuration::resetPDO();
|
||||||
|
Configuration::$pdoDSN = 'pgsql:host=localhost;user=mpj;password=devpassword;dbname=mpj';
|
||||||
|
$reqs = Custom::array('SELECT data FROM mpj.request', [], new ArrayMapper());
|
||||||
|
|
||||||
|
echo 'Found ' . sizeof($reqs) . ' requests; migrating to v4...' . PHP_EOL;
|
||||||
|
|
||||||
|
Configuration::resetPDO();
|
||||||
|
Configuration::$mode = Mode::SQLite;
|
||||||
|
Configuration::$pdoDSN = 'sqlite:./data/mpj.db';
|
||||||
|
|
||||||
|
Definition::ensureTable(Table::REQUEST);
|
||||||
|
|
||||||
|
/** Convert dates to the same format */
|
||||||
|
function convertDate(string $date): string
|
||||||
|
{
|
||||||
|
return (new DateTimeImmutable($date))->format('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($reqs as $reqJson) {
|
||||||
|
$req = json_decode($reqJson['data']);
|
||||||
|
$notes = array_map(fn(stdClass $note) => new Note(convertDate($note->asOf), $note->notes), $req->notes ?? []);
|
||||||
|
$history = array_map(fn(stdClass $hist) =>
|
||||||
|
new History(
|
||||||
|
asOf: convertDate($hist->asOf),
|
||||||
|
action: RequestAction::from($hist->status),
|
||||||
|
text: property_exists($hist, 'text') ? $hist->text : null),
|
||||||
|
$req->history);
|
||||||
|
$recurParts = explode(' ', $req->recurrence);
|
||||||
|
$recurPeriod = RecurrencePeriod::from(end($recurParts));
|
||||||
|
$recur = match ($recurPeriod) {
|
||||||
|
RecurrencePeriod::Immediate => new Recurrence(RecurrencePeriod::Immediate),
|
||||||
|
default => new Recurrence($recurPeriod, (int)$recurParts[0])
|
||||||
|
};
|
||||||
|
$v4Req = new Request(
|
||||||
|
id: $req->id,
|
||||||
|
enteredOn: convertDate($req->enteredOn),
|
||||||
|
userId: $req->userId,
|
||||||
|
snoozedUntil: property_exists($req, 'snoozedUntil') ? convertDate($req->snoozedUntil) : null,
|
||||||
|
showAfter: property_exists($req, 'showAfter') ? convertDate($req->showAfter) : null,
|
||||||
|
recurrence: $recur,
|
||||||
|
history: $history,
|
||||||
|
notes: $notes);
|
||||||
|
Document::insert(Table::REQUEST, $v4Req);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo PHP_EOL . 'done' . PHP_EOL;
|
||||||
79
src/lib/Auth.php
Normal file
79
src/lib/Auth.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use Auth0\SDK\Auth0;
|
||||||
|
use Auth0\SDK\Exception\ConfigurationException;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
private static ?Auth0 $auth0 = null;
|
||||||
|
|
||||||
|
public static function client(): Auth0
|
||||||
|
{
|
||||||
|
if (is_null(self::$auth0)) {
|
||||||
|
self::$auth0 = new Auth0([
|
||||||
|
'domain' => $_ENV['AUTH0_DOMAIN'],
|
||||||
|
'clientId' => $_ENV['AUTH0_CLIENT_ID'],
|
||||||
|
'clientSecret' => $_ENV['AUTH0_CLIENT_SECRET'],
|
||||||
|
'cookieSecret' => $_ENV['AUTH0_COOKIE_SECRET']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return self::$auth0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the logged on user information
|
||||||
|
*
|
||||||
|
* @return array|null The user information (null if no user is logged on)
|
||||||
|
*/
|
||||||
|
public static function user(): ?array
|
||||||
|
{
|
||||||
|
return self::client()->getUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a log on with Auth0
|
||||||
|
*
|
||||||
|
* @throws ConfigurationException If the Auth0 client is not configured correctly
|
||||||
|
*/
|
||||||
|
public static function logOn(): never
|
||||||
|
{
|
||||||
|
$params = match (true) {
|
||||||
|
$_SERVER['PHP_SELF'] <> '/user/log-on.php' => ['redirectUri' => $_SERVER['PHP_SELF']],
|
||||||
|
default => []
|
||||||
|
};
|
||||||
|
|
||||||
|
self::client()->clear();
|
||||||
|
header('Location: ' . self::client()->login($_ENV['AUTH0_BASE_URL'] . '/user/log-on/success', $params));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log off from this application and Auth0
|
||||||
|
*
|
||||||
|
* @throws ConfigurationException If the Auth0 client is not configured correctly
|
||||||
|
*/
|
||||||
|
public static function logOff(): never
|
||||||
|
{
|
||||||
|
session_destroy();
|
||||||
|
header('Location: ' . self::client()->logout($_ENV['AUTH0_BASE_URL']));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a user be logged on
|
||||||
|
*
|
||||||
|
* @param bool $redirect Whether to redirect to log on if there is not a user logged on
|
||||||
|
* @return void If it returns, there is a user logged on; if not, we will be redirected to log on
|
||||||
|
* @throws ConfigurationException If the Auth0 client is not configured correctly
|
||||||
|
*/
|
||||||
|
public static function requireUser(bool $redirect = true): void
|
||||||
|
{
|
||||||
|
if (is_null(self::user())) {
|
||||||
|
if ($redirect) self::logOn();
|
||||||
|
http_response_code(403);
|
||||||
|
die('Not Authorized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib/History.php
Normal file
25
src/lib/History.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A record of an action taken on a request
|
||||||
|
*/
|
||||||
|
class History implements JsonSerializable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $asOf The date/time this entry was made
|
||||||
|
* @param RequestAction $action The action taken for this history entry
|
||||||
|
* @param string|null $text The text for this history entry (optional)
|
||||||
|
*/
|
||||||
|
public function __construct(public string $asOf, public RequestAction $action, public ?string $text = null) { }
|
||||||
|
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
$values = ['asOf' => $this->asOf, 'action' => $this->action->value];
|
||||||
|
if (isset($this->text)) $values['text'] = $this->text;
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/lib/Layout.php
Normal file
181
src/lib/Layout.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use BitBadger\PDODocument\Custom;
|
||||||
|
use BitBadger\PDODocument\Mapper\ExistsMapper;
|
||||||
|
|
||||||
|
class Layout
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate the heading for a bare result
|
||||||
|
*/
|
||||||
|
public static function bareHead(): void
|
||||||
|
{
|
||||||
|
echo '<!DOCTYPE html><html lang=en><head><title></title></head><body>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the end of a bare result
|
||||||
|
*/
|
||||||
|
public static function bareFoot(): void
|
||||||
|
{
|
||||||
|
echo '</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this an htmx request?
|
||||||
|
*
|
||||||
|
* @return bool True if this is an htmx request, false if not
|
||||||
|
*/
|
||||||
|
private static function isHtmx(): bool
|
||||||
|
{
|
||||||
|
return key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the `DOCTYPE` declaration, `html`, and `head` tags for the page
|
||||||
|
*
|
||||||
|
* @param string $title The title of the page
|
||||||
|
*/
|
||||||
|
public static function htmlHead(string $title): void
|
||||||
|
{
|
||||||
|
if (self::isHtmx()) {
|
||||||
|
echo "<!DOCTYPE html><html lang=en><head lang=en><title>$title « myPrayerJournal</title></head>";
|
||||||
|
} else {
|
||||||
|
echo <<<HEAD
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<head>
|
||||||
|
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||||
|
<meta name=description content="Online prayer journal - free w/Google or Microsoft account">
|
||||||
|
<meta name=htmx-config content='{"historyCacheSize":0}'>
|
||||||
|
<title>$title « myPrayerJournal</title>
|
||||||
|
<link href=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css rel=stylesheet
|
||||||
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||||
|
crossorigin=anonymous>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel=stylesheet>
|
||||||
|
<link href=/style/style.css rel=stylesheet>
|
||||||
|
</head>
|
||||||
|
HEAD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function navLink(string $url, string $text): void
|
||||||
|
{
|
||||||
|
$classAttr = match (true) {
|
||||||
|
str_starts_with($_SERVER['PHP_SELF'], $url) => ['class' => 'is-active-route'],
|
||||||
|
default => []
|
||||||
|
};
|
||||||
|
echo '<li class=nav-item>';
|
||||||
|
UI::pageLink($url, $text, $classAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
|
||||||
|
*/
|
||||||
|
public static function navBar(): void
|
||||||
|
{
|
||||||
|
$table = Table::REQUEST;
|
||||||
|
$hasSnoozed = key_exists('user_id', $_SESSION)
|
||||||
|
? Custom::scalar(<<<SQL
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM $table
|
||||||
|
WHERE data->>'userId' = :userId AND datetime(data->>'snoozedUntil') > datetime('now'))
|
||||||
|
SQL, [':userId' => $_SESSION['user_id']], new ExistsMapper())
|
||||||
|
: false; ?>
|
||||||
|
<nav class="navbar navbar-dark" role="navigation">
|
||||||
|
<div class=container-fluid><?php
|
||||||
|
UI::pageLink('/', '<span class=m>my</span><span class=p>Prayer</span><span class=j>Journal</span>',
|
||||||
|
['class' => 'navbar-brand']); ?>
|
||||||
|
<ul class="navbar-nav me-auto d-flex flex-row"><?php
|
||||||
|
if (key_exists('user_id', $_SESSION)) {
|
||||||
|
self::navLink('/journal', 'Journal');
|
||||||
|
self::navLink('/requests/active', 'Active');
|
||||||
|
if ($hasSnoozed) self::navLink('/requests/snoozed', 'Snoozed');
|
||||||
|
self::navLink('/requests/answered', 'Answered'); ?>
|
||||||
|
<li class=nav-item><a href=/user/log-off>Log Off</a><?php
|
||||||
|
} else { ?>
|
||||||
|
<li class=nav-item><a href=/user/log-on>Log On</a><?php
|
||||||
|
}
|
||||||
|
self::navLink('/docs', 'Docs'); ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav><?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop .0 or .0.0 from the end of the version to format it for display
|
||||||
|
*
|
||||||
|
* @return string The version of the application for user display
|
||||||
|
*/
|
||||||
|
private static function displayVersion(): string {
|
||||||
|
[$major, $minor, $rev] = explode('.', MPJ_VERSION);
|
||||||
|
$minor = $minor == '0' ? '' : ".$minor";
|
||||||
|
$rev = match (true) {
|
||||||
|
$rev == '0' => '',
|
||||||
|
str_starts_with($rev, '0-') => substr($rev, 1),
|
||||||
|
default => ".$rev"
|
||||||
|
};
|
||||||
|
return "v$major$minor$rev";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the footer
|
||||||
|
*/
|
||||||
|
public static function htmlFoot(): void
|
||||||
|
{ ?>
|
||||||
|
<footer class=container-fluid>
|
||||||
|
<p class="text-muted text-end">
|
||||||
|
myPrayerJournal <?=self::displayVersion();?><br>
|
||||||
|
<em><small><?php
|
||||||
|
UI::pageLink('/legal/privacy-policy', 'Privacy Policy');
|
||||||
|
echo ' • ';
|
||||||
|
UI::pageLink('/legal/terms-of-service', 'Terms of Service');
|
||||||
|
echo ' • '; ?>
|
||||||
|
<a href=https://git.bitbadger.solutions/bit-badger/myPrayerJournal target=_blank
|
||||||
|
rel=noopener>Developed</a> and hosted by
|
||||||
|
<a href=https://bitbadger.solutions target=_blank rel=noopener>Bit Badger Solutions</a>
|
||||||
|
</small></em>
|
||||||
|
<script src=https://unpkg.com/htmx.org@2.0.0 crossorigin=anonymous
|
||||||
|
integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw"></script>
|
||||||
|
<script>if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')</script>
|
||||||
|
<script async src=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js
|
||||||
|
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||||
|
crossorigin=anonymous></script>
|
||||||
|
<script>
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>')
|
||||||
|
}, 2000)
|
||||||
|
</script>
|
||||||
|
<script src=/script/mpj.js></script>
|
||||||
|
</footer><?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the heading for a full or partial page result
|
||||||
|
*
|
||||||
|
* @param string $title The title of the page
|
||||||
|
*/
|
||||||
|
public static function pageHead(string $title): void
|
||||||
|
{
|
||||||
|
self::htmlHead($title);
|
||||||
|
echo '<body>';
|
||||||
|
if (!self::isHtmx()) echo '<section id=top aria-label="Top navigation">';
|
||||||
|
self::navBar();
|
||||||
|
echo '<main role=main>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the end of the page for a full or partial page result
|
||||||
|
*/
|
||||||
|
public static function pageFoot(): void
|
||||||
|
{
|
||||||
|
echo '</main>';
|
||||||
|
if (!self::isHtmx()) {
|
||||||
|
echo '</section>';
|
||||||
|
self::htmlFoot();
|
||||||
|
}
|
||||||
|
echo '</body></html>';
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/lib/Note.php
Normal file
30
src/lib/Note.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use BitBadger\PDODocument\DocumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note entered on a prayer request
|
||||||
|
*/
|
||||||
|
class Note
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $asOf The date/time this note was recorded
|
||||||
|
* @param string $text The text of the note
|
||||||
|
*/
|
||||||
|
public function __construct(public string $asOf, public string $text) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve notes for a given request
|
||||||
|
*
|
||||||
|
* @param string $id The ID of the request for which notes should be retrieved
|
||||||
|
* @return array|Note[] The notes for the request, or an empty array if the request was not found
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function byRequestId(string $id): array
|
||||||
|
{
|
||||||
|
$req = Request::byId($id);
|
||||||
|
return $req ? $req->notes : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/lib/Recurrence.php
Normal file
41
src/lib/Recurrence.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use DateInterval;
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recurrence for a prayer request
|
||||||
|
*/
|
||||||
|
class Recurrence implements JsonSerializable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param RecurrencePeriod $period The recurrence period
|
||||||
|
* @param int|null $interval How many of the periods will pass before the request is visible again
|
||||||
|
*/
|
||||||
|
public function __construct(public RecurrencePeriod $period, public ?int $interval = null) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the date/time interval for this recurrence
|
||||||
|
*
|
||||||
|
* @return DateInterval The interval matching the recurrence
|
||||||
|
*/
|
||||||
|
public function interval(): DateInterval
|
||||||
|
{
|
||||||
|
$period = match ($this->period) {
|
||||||
|
RecurrencePeriod::Immediate => 'T0S',
|
||||||
|
RecurrencePeriod::Hours => "T{$this->interval}H",
|
||||||
|
RecurrencePeriod::Days => "{$this->interval}D",
|
||||||
|
RecurrencePeriod::Weeks => ($this->interval * 7) . 'D'
|
||||||
|
};
|
||||||
|
return new DateInterval("P$period");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
$values = ['period' => $this->period->value];
|
||||||
|
if (isset($this->interval)) $values['interval'] = $this->interval;
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/lib/RecurrencePeriod.php
Normal file
21
src/lib/RecurrencePeriod.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of recurrence a request can have
|
||||||
|
*/
|
||||||
|
enum RecurrencePeriod: string
|
||||||
|
{
|
||||||
|
/** Requests, once prayed, are available again immediately */
|
||||||
|
case Immediate = 'Immediate';
|
||||||
|
|
||||||
|
/** Requests, once prayed, appear again in a number of hours */
|
||||||
|
case Hours = 'Hours';
|
||||||
|
|
||||||
|
/** Requests, once prayed, appear again in a number of days */
|
||||||
|
case Days = 'Days';
|
||||||
|
|
||||||
|
/** Requests, once prayed, appear again in a number of weeks */
|
||||||
|
case Weeks = 'Weeks';
|
||||||
|
}
|
||||||
206
src/lib/Request.php
Normal file
206
src/lib/Request.php
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use BitBadger\PDODocument\{Custom, DocumentException, DocumentList, Find, Mapper\DocumentMapper};
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Exception;
|
||||||
|
use JsonSerializable;
|
||||||
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prayer request
|
||||||
|
*/
|
||||||
|
class Request implements JsonSerializable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $id The ID for the request
|
||||||
|
* @param string $enteredOn The date/time this request was originally entered
|
||||||
|
* @param string $userId The ID of the user to whom this request belongs
|
||||||
|
* @param string|null $snoozedUntil The date/time the snooze expires for this request (null = not snoozed)
|
||||||
|
* @param string|null $showAfter The date/time the current recurrence period is over (null = immediate)
|
||||||
|
* @param Recurrence $recurrence The recurrence for this request
|
||||||
|
* @param History[] $history The history of this request
|
||||||
|
* @param Note[] $notes Notes regarding this request
|
||||||
|
* @throws Exception If the ID generation fails
|
||||||
|
*/
|
||||||
|
public function __construct(public string $id = '', public string $enteredOn = '', public string $userId = '',
|
||||||
|
public ?string $snoozedUntil = null, public ?string $showAfter = null,
|
||||||
|
public Recurrence $recurrence = new Recurrence(RecurrencePeriod::Immediate),
|
||||||
|
public array $history = [], public array $notes = [])
|
||||||
|
{
|
||||||
|
if ($id == '') {
|
||||||
|
$this->id = (new Cuid2())->toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current text for this request
|
||||||
|
*
|
||||||
|
* @return string The most recent text for the request
|
||||||
|
*/
|
||||||
|
public function currentText(): string
|
||||||
|
{
|
||||||
|
foreach ($this->history as $hist) if (isset($hist->text)) return $hist->text;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the date/time this request was last marked as prayed
|
||||||
|
*
|
||||||
|
* @return string|null The date/time this request was last marked as prayed
|
||||||
|
*/
|
||||||
|
public function lastPrayed(): ?string
|
||||||
|
{
|
||||||
|
foreach ($this->history as $hist) if ($hist->action == RequestAction::Prayed) return $hist->asOf;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has this request been answered?
|
||||||
|
*
|
||||||
|
* @return bool True if the request is answered, false if not
|
||||||
|
*/
|
||||||
|
public function isAnswered(): bool
|
||||||
|
{
|
||||||
|
return $this->history[0]->action == RequestAction::Answered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this request currently snoozed?
|
||||||
|
*
|
||||||
|
* @return bool True if the request is snoozed, false if not
|
||||||
|
* @throws Exception If the snoozed until date/time is not valid
|
||||||
|
*/
|
||||||
|
public function isSnoozed(): bool
|
||||||
|
{
|
||||||
|
return isset($this->snoozedUntil) && new DateTimeImmutable($this->snoozedUntil) > new DateTimeImmutable('now');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this request currently not shown due to recurrence?
|
||||||
|
*
|
||||||
|
* @return bool True if the request is pending, false if not
|
||||||
|
* @throws Exception If the snoozed or show-after date/times are not valid
|
||||||
|
*/
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return !$this->isSnoozed()
|
||||||
|
&& isset($this->showAfter)
|
||||||
|
&& new DateTimeImmutable($this->showAfter) > new DateTimeImmutable('now');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
$values = [
|
||||||
|
'id' => $this->id,
|
||||||
|
'enteredOn' => $this->enteredOn,
|
||||||
|
'userId' => $this->userId,
|
||||||
|
'recurrence' => $this->recurrence,
|
||||||
|
'history' => $this->history,
|
||||||
|
'notes' => $this->notes
|
||||||
|
];
|
||||||
|
if (isset($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
|
||||||
|
if (isset($this->showAfter)) $values['showAfter'] = $this->showAfter;
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a request by its ID
|
||||||
|
*
|
||||||
|
* @param string $id The ID of the request
|
||||||
|
* @return Request|false The request if it is found and belongs to the current user, false if not
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function byId(string $id): Request|false
|
||||||
|
{
|
||||||
|
$req = Find::byId(Table::REQUEST, $id, self::class);
|
||||||
|
return ($req && $req->userId == $_SESSION['user_id']) ? $req : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's active journal requests
|
||||||
|
*
|
||||||
|
* @return DocumentList<Request> The requests for the user's journal
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function forJournal(): DocumentList
|
||||||
|
{
|
||||||
|
$table = Table::REQUEST;
|
||||||
|
return Custom::list(<<<SQL
|
||||||
|
SELECT data, (
|
||||||
|
SELECT h.value->>'asOf' as_of
|
||||||
|
FROM $table i LEFT JOIN json_each(i.data, '$.history') h
|
||||||
|
WHERE r.data->>'id' = i.data->>'id' AND h.value->>'action' = 'Prayed'
|
||||||
|
LIMIT 1) last_prayed
|
||||||
|
FROM $table r
|
||||||
|
WHERE data->>'userId' = :userId
|
||||||
|
AND data->>'$.history[0].action' <> 'Answered'
|
||||||
|
AND (data->>'snoozedUntil' IS NULL OR data->>'snoozedUntil' < datetime('now'))
|
||||||
|
AND (data->>'showAfter' IS NULL OR data->>'showAfter' < datetime('now'))
|
||||||
|
ORDER BY coalesce(last_prayed, data->>'snoozedUntil', data->>'showAfter', data->>'$.history[0].asOf')
|
||||||
|
SQL, [':userId' => $_SESSION['user_id']], new DocumentMapper(self::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get either the user's active or answered requests
|
||||||
|
*
|
||||||
|
* @param bool $active True to retrieve active requests, false to retrieve answered requests
|
||||||
|
* @param bool $snoozed True to retrieve only snoozed requests
|
||||||
|
* @return DocumentList<Request> The requests matching the criteria
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
private static function forUser(bool $active = true, bool $snoozed = false): DocumentList
|
||||||
|
{
|
||||||
|
$table = Table::REQUEST;
|
||||||
|
$op = $active ? '<>' : '=';
|
||||||
|
$extra = $snoozed ? "AND datetime(data->>'snoozedUntil') > datetime('now')" : '';
|
||||||
|
$order = $active
|
||||||
|
? "coalesce(data->>'snoozedUntil', data->>'showAfter', last_prayed, data->>'$.history[0].asOf')"
|
||||||
|
: "data->>'$.history[0].asOf' DESC";
|
||||||
|
return Custom::list(<<<SQL
|
||||||
|
SELECT data, (
|
||||||
|
SELECT h.value->>'asOf' as_of
|
||||||
|
FROM $table i LEFT JOIN json_each(i.data, '$.history') h
|
||||||
|
WHERE r.data->>'id' = i.data->>'id' AND h.value->>'action' = 'Prayed'
|
||||||
|
LIMIT 1) last_prayed
|
||||||
|
FROM $table r
|
||||||
|
WHERE data->>'userId' = :userId
|
||||||
|
AND data->>'$.history[0].action' $op 'Answered' $extra
|
||||||
|
ORDER BY $order
|
||||||
|
SQL, [':userId' => $_SESSION['user_id']], new DocumentMapper(self::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of active requests for a user
|
||||||
|
*
|
||||||
|
* @return DocumentList<Request> The user's active requests
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function active(): DocumentList
|
||||||
|
{
|
||||||
|
return self::forUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of answered requests for a user
|
||||||
|
*
|
||||||
|
* @return DocumentList<Request> The user's answered requests
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function answered(): DocumentList
|
||||||
|
{
|
||||||
|
return self::forUser(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of snoozed requests for a user
|
||||||
|
*
|
||||||
|
* @return DocumentList<Request> The user's snoozed requests
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function snoozed(): DocumentList
|
||||||
|
{
|
||||||
|
return self::forUser(snoozed: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/lib/RequestAction.php
Normal file
21
src/lib/RequestAction.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An action taken on a prayer request
|
||||||
|
*/
|
||||||
|
enum RequestAction: string
|
||||||
|
{
|
||||||
|
/** The request was created */
|
||||||
|
case Created = 'Created';
|
||||||
|
|
||||||
|
/** The request was marked as having been prayed for */
|
||||||
|
case Prayed = 'Prayed';
|
||||||
|
|
||||||
|
/** The request was updated */
|
||||||
|
case Updated = 'Updated';
|
||||||
|
|
||||||
|
/** The request was marked as answered */
|
||||||
|
case Answered = 'Answered';
|
||||||
|
}
|
||||||
12
src/lib/Table.php
Normal file
12
src/lib/Table.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for table names
|
||||||
|
*/
|
||||||
|
class Table
|
||||||
|
{
|
||||||
|
/** @var string The prayer request table used by myPrayerJournal */
|
||||||
|
const REQUEST = 'request';
|
||||||
|
}
|
||||||
238
src/lib/UI.php
Normal file
238
src/lib/UI.php
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use BitBadger\PDODocument\{DocumentException, DocumentList};
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User interface building blocks
|
||||||
|
*/
|
||||||
|
class UI
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate a material icon
|
||||||
|
*
|
||||||
|
* @param string $name The name of the material icon
|
||||||
|
* @return string The material icon wrapped in a `span` tag
|
||||||
|
*/
|
||||||
|
public static function icon(string $name): string {
|
||||||
|
return "<span class=material-icons>$name</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the journal items for the current user
|
||||||
|
*
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
public static function journal(): void
|
||||||
|
{
|
||||||
|
Layout::bareHead();
|
||||||
|
$reqs = Request::forJournal();
|
||||||
|
if ($reqs->hasItems()) { ?>
|
||||||
|
<section id=journalItems class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" hx-target=this
|
||||||
|
hx-swap=outerHTML aria-label="Prayer Requests"><?php
|
||||||
|
$spacer = '<span> </span>';
|
||||||
|
foreach ($reqs->items() as /** @var Request $req */ $req) { ?>
|
||||||
|
<div class=col>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header p-0 d-flex" role=toolbar><?php
|
||||||
|
self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
|
||||||
|
['class' => 'btn btn-secondary', 'title' => 'Edit Request']); ?>
|
||||||
|
<?=$spacer?>
|
||||||
|
<button type=button class="btn btn-secondary" title="Add Notes" data-bs-toggle=modal
|
||||||
|
data-bs-target=#notesModal hx-get="/components/request/add-note?id=<?=$req->id?>"
|
||||||
|
hx-target=#notesBody hx-swap=innerHTML><?=self::icon('comment');?></button>
|
||||||
|
<?=$spacer?>
|
||||||
|
<button type=button class="btn btn-secondary" title="Snooze Request" data-bs-toggle=modal
|
||||||
|
data-bs-target=#snoozeModal hx-get="/components/request/snooze?id=<?=$req->id?>"
|
||||||
|
hx-target=#snoozeBody hx-swap=innerHTML><?=self::icon('schedule');?></button>
|
||||||
|
<div class=flex-grow-1></div>
|
||||||
|
<button type=button class="btn btn-success w-25" hx-patch="/request/prayed?id=<?=$req->id?>"
|
||||||
|
title="Mark as Prayed"><?=self::icon('done');?></button>
|
||||||
|
</div>
|
||||||
|
<div class=card-body>
|
||||||
|
<p class=request-text><?=htmlentities($req->currentText());?>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end text-muted px-1 py-0">
|
||||||
|
<em><?php
|
||||||
|
$lastPrayed = $req->lastPrayed();
|
||||||
|
echo 'last ' . (is_null($lastPrayed) ? 'activity': 'prayed') . ' ';
|
||||||
|
self::relativeDate($lastPrayed ?? $req->history[0]->asOf); ?>
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><?php
|
||||||
|
} ?>
|
||||||
|
</section><?php
|
||||||
|
} else {
|
||||||
|
UI::noResults('No Active Requests', '/request/edit?id=new', 'Add a Request', <<<'TEXT'
|
||||||
|
You have no requests to be shown; see the “Active” link above for snoozed or deferred
|
||||||
|
requests, and the “Answered” link for answered requests
|
||||||
|
TEXT);
|
||||||
|
}
|
||||||
|
Layout::bareFoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a card when there are no results found
|
||||||
|
*/
|
||||||
|
public static function noResults(string $heading, string $link, string $buttonText, string $text): void
|
||||||
|
{ ?>
|
||||||
|
<div class=card>
|
||||||
|
<h5 class=card-header><?=$heading?></h5>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class=card-text><?=$text?></p><?php
|
||||||
|
self::pageLink($link, $buttonText, ['class' => 'btn btn-primary']); ?>
|
||||||
|
</div>
|
||||||
|
</div><?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a link to a page within myPrayerJournal
|
||||||
|
*
|
||||||
|
* @param string $href The URL for the link
|
||||||
|
* @param string $text The text for the link
|
||||||
|
* @param array $attrs Any additional attributes that should be placed on the `a` tag
|
||||||
|
*/
|
||||||
|
public static function pageLink(string $href, string $text, array $attrs = []): void
|
||||||
|
{ ?>
|
||||||
|
<a href="<?=$href?>" hx-get="<?=$href?>" hx-target=#top hx-swap=innerHTML hx-push-url=true<?php
|
||||||
|
foreach ($attrs as $key => $value) echo " $key=\"" . htmlspecialchars($value) . "\""; ?>><?=$text?></a><?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function relativeDate(string $date): void
|
||||||
|
{
|
||||||
|
$parsed = new DateTimeImmutable($date);
|
||||||
|
$inZone = $parsed->setTimezone(new DateTimeZone($_REQUEST['time_zone']));
|
||||||
|
echo '<span title="' . date_format($inZone, 'l, F j, Y \a\t g:ia T') . '">'
|
||||||
|
. self::formatDistance('now', $parsed) . '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Many thanks to date-fns (https://date-fns.org) for this logic
|
||||||
|
/**
|
||||||
|
* Format the distance between two dates
|
||||||
|
*
|
||||||
|
* @param string|DateTimeImmutable $from The starting date/time
|
||||||
|
* @param string|DateTimeImmutable $to The ending date/time
|
||||||
|
* @return string The distance between two dates
|
||||||
|
* @throws Exception If date/time objects cannot be created
|
||||||
|
*/
|
||||||
|
public static function formatDistance(string|DateTimeImmutable $from, string|DateTimeImmutable $to): string
|
||||||
|
{
|
||||||
|
$aDay = 1_440.0;
|
||||||
|
$almost2Days = 2_520.0;
|
||||||
|
$aMonth = 43_200.0;
|
||||||
|
$twoMonths = 86_400.0;
|
||||||
|
|
||||||
|
$dtFrom = is_string($from) ? new DateTimeImmutable($from) : $from;
|
||||||
|
$dtTo = is_string($to) ? new DateTimeImmutable($to) : $to;
|
||||||
|
$minutes = abs($dtFrom->getTimestamp() - $dtTo->getTimestamp()) / 60;
|
||||||
|
$months = round($minutes / $aMonth);
|
||||||
|
$years = round($months / 12);
|
||||||
|
|
||||||
|
$typeAndNumber = match (true) {
|
||||||
|
$minutes < 1.0 => [FormatDistanceToken::LessThanXMinutes, 1],
|
||||||
|
$minutes < 45.0 => [FormatDistanceToken::XMinutes, round($minutes)],
|
||||||
|
$minutes < 90.0 => [FormatDistanceToken::AboutXHours, 1],
|
||||||
|
$minutes < $aDay => [FormatDistanceToken::AboutXHours, round($minutes / 60)],
|
||||||
|
$minutes < $almost2Days => [FormatDistanceToken::XDays, 1],
|
||||||
|
$minutes < $aMonth => [FormatDistanceToken::XDays, round($minutes / $aDay)],
|
||||||
|
$minutes < $twoMonths => [FormatDistanceToken::AboutXMonths, round($minutes / $aMonth)],
|
||||||
|
$months < 12 => [FormatDistanceToken::XMonths, round($minutes / $aMonth)],
|
||||||
|
$months % 12 < 3 => [FormatDistanceToken::AboutXYears, $years],
|
||||||
|
$months % 12 < 9 => [FormatDistanceToken::OverXYears, $years],
|
||||||
|
default => [FormatDistanceToken::AlmostXYears, $years]
|
||||||
|
};
|
||||||
|
$format = match ($typeAndNumber[0]) {
|
||||||
|
FormatDistanceToken::LessThanXMinutes => ['less than a minute', 'less than %d minutes'],
|
||||||
|
FormatDistanceToken::XMinutes => ['a minute', '%d minutes'],
|
||||||
|
FormatDistanceToken::AboutXHours => ['about an hour', 'about %d hours'],
|
||||||
|
FormatDistanceToken::XHours => ['an hour', '%d hours'],
|
||||||
|
FormatDistanceToken::XDays => ['a day', '%d days'],
|
||||||
|
FormatDistanceToken::AboutXWeeks => ['about a week', 'about %d weeks'],
|
||||||
|
FormatDistanceToken::XWeeks => ['a week', '%d weeks'],
|
||||||
|
FormatDistanceToken::AboutXMonths => ['about a month', 'about %d months'],
|
||||||
|
FormatDistanceToken::XMonths => ['a month', '%d months'],
|
||||||
|
FormatDistanceToken::AboutXYears => ['about a year', 'about %d years'],
|
||||||
|
FormatDistanceToken::XYears => ['a year', '%d years'],
|
||||||
|
FormatDistanceToken::OverXYears => ['over a year', 'over %d years'],
|
||||||
|
FormatDistanceToken::AlmostXYears => ['almost a year', 'almost %d years']
|
||||||
|
};
|
||||||
|
$value = $typeAndNumber[1] == 1 ? $format[0] : sprintf($format[1], $typeAndNumber[1]);
|
||||||
|
return $dtFrom > $dtTo ? "$value ago" : "in $value";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function requestItem(Request $req): void
|
||||||
|
{
|
||||||
|
$btnClass = "btn btn-light mx-2";
|
||||||
|
$restoreBtn = fn(string $id, string $link, string $title) =>
|
||||||
|
'<button class="' . $btnClass. '" hx-patch="/request/' . $link . '?id=' . $id
|
||||||
|
. '" title="' . htmlspecialchars($title) . '">' . self::icon('restore') . '</button>'; ?>
|
||||||
|
<div class="list-group-item px-0 d-flex flex-row align-items-start" hx-target=this
|
||||||
|
hx-swap=outerHTML><?php
|
||||||
|
self::pageLink("/request/full?id=$req->id", self::icon('description'),
|
||||||
|
['class' => $btnClass, 'title' => 'View Full Request']);
|
||||||
|
if (!$req->isAnswered()) {
|
||||||
|
self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
|
||||||
|
['class' => $btnClass, 'title' => 'Edit Request']);
|
||||||
|
}
|
||||||
|
if ($req->isSnoozed()) {
|
||||||
|
echo $restoreBtn($req->id, 'cancel-snooze', 'Cancel Snooze');
|
||||||
|
} elseif ($req->isPending()) {
|
||||||
|
echo $restoreBtn($req->id, 'show', 'Show Now');
|
||||||
|
}
|
||||||
|
echo '<p class="request-text mb-0">' . $req->currentText();
|
||||||
|
if ($req->isSnoozed() || $req->isPending() || $req->isAnswered()) { ?>
|
||||||
|
<br>
|
||||||
|
<small class=text-muted><em><?php
|
||||||
|
switch (true) {
|
||||||
|
case $req->isSnoozed():
|
||||||
|
echo 'Snooze expires '; self::relativeDate($req->snoozedUntil);
|
||||||
|
break;
|
||||||
|
case $req->isPending():
|
||||||
|
echo 'Request appears next '; self::relativeDate($req->showAfter);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
echo 'Answered '; self::relativeDate($req->history[0]->asOf);
|
||||||
|
} ?>
|
||||||
|
</em></small><?php
|
||||||
|
} ?>
|
||||||
|
</div><?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the given list of requests
|
||||||
|
*
|
||||||
|
* @param DocumentList<Request> $reqs The list of requests to render
|
||||||
|
* @throws Exception If date/time instances are not valid
|
||||||
|
*/
|
||||||
|
public static function requestList(DocumentList $reqs): void
|
||||||
|
{
|
||||||
|
echo '<div class=list-group>';
|
||||||
|
foreach ($reqs->items() as $req) self::requestItem($req);
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FormatDistanceToken
|
||||||
|
{
|
||||||
|
case LessThanXMinutes;
|
||||||
|
case XMinutes;
|
||||||
|
case AboutXHours;
|
||||||
|
case XHours;
|
||||||
|
case XDays;
|
||||||
|
case AboutXWeeks;
|
||||||
|
case XWeeks;
|
||||||
|
case AboutXMonths;
|
||||||
|
case XMonths;
|
||||||
|
case AboutXYears;
|
||||||
|
case XYears;
|
||||||
|
case OverXYears;
|
||||||
|
case AlmostXYears;
|
||||||
|
}
|
||||||
10
src/public/components/journal-items.php
Normal file
10
src/public/components/journal-items.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, UI};
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
Auth::requireUser(false);
|
||||||
|
|
||||||
|
UI::journal();
|
||||||
24
src/public/components/request/add-note.php
Normal file
24
src/public/components/request/add-note.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, Layout, Request};
|
||||||
|
|
||||||
|
require '../../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['GET'], false);
|
||||||
|
|
||||||
|
Layout::bareHead(); ?>
|
||||||
|
<form hx-post="/request/note?id=<?=$req->id?>">
|
||||||
|
<div class="form-floating pb-3">
|
||||||
|
<textarea id=notes name=notes class=form-control style="min-height: 8rem;" placeholder=Notes autofocus
|
||||||
|
required></textarea>
|
||||||
|
<label for=notes>Notes</label>
|
||||||
|
</div>
|
||||||
|
<p class=text-end><button type=submit class="btn btn-primary">Add Notes</button>
|
||||||
|
</form>
|
||||||
|
<hr style="margin: .5rem -1rem">
|
||||||
|
<div id=priorNotes>
|
||||||
|
<p class="text-center pt-3">
|
||||||
|
<button type=button class="btn btn-secondary" hx-get="/components/request/notes?id=<?=$req->id?>"
|
||||||
|
hx-swap=outerHTML hx-target=#priorNotes>Load Prior Notes</button>
|
||||||
|
</div><?php
|
||||||
|
Layout::bareFoot();
|
||||||
19
src/public/components/request/notes.php
Normal file
19
src/public/components/request/notes.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Layout, UI};
|
||||||
|
|
||||||
|
require '../../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['GET'], false);
|
||||||
|
|
||||||
|
Layout::bareHead();?>
|
||||||
|
<p class=text-center><strong>Prior Notes for This Request</strong><?php
|
||||||
|
if (sizeof($req->notes) > 0) {
|
||||||
|
foreach ($req->notes as $note) { ?>
|
||||||
|
<p><small class=text-muted><?php UI::relativeDate($note->asOf); ?></small><br>
|
||||||
|
<?=htmlentities($note->text)?><?php
|
||||||
|
}
|
||||||
|
} else { ?>
|
||||||
|
<p class="text-center text-muted">There are no prior notes for this request<?php
|
||||||
|
}
|
||||||
|
Layout::bareFoot();
|
||||||
18
src/public/components/request/snooze.php
Normal file
18
src/public/components/request/snooze.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Layout;
|
||||||
|
|
||||||
|
require '../../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['GET'], false);
|
||||||
|
|
||||||
|
Layout::bareHead(); ?>
|
||||||
|
<form hx-patch="/request/snooze?id=<?=$req->id?>" hx-target=#journalItems hx-swap=outerHTML>
|
||||||
|
<div class="form-floating pb-3">
|
||||||
|
<input type=date id=until name=until class=form-control
|
||||||
|
min="<?=(new DateTimeImmutable('now'))->format('Y-m-d')?>" required>
|
||||||
|
<label for=until>Until</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-end mb-0"><button type=submit class="btn btn-primary">Snooze</button>
|
||||||
|
</form><?php
|
||||||
|
Layout::bareFoot();
|
||||||
102
src/public/docs.php
Normal file
102
src/public/docs.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Layout;
|
||||||
|
|
||||||
|
require '../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Layout::pageHead('Documentation'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<h2 class=mb-3>Documentation</h2>
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">About myPrayerJournal</h3>
|
||||||
|
<p>Journaling has a long history; it helps people remember what happened, and the act of writing helps people think
|
||||||
|
about what happened and process it. A prayer journal is not a new concept; it helps you keep track of the
|
||||||
|
requests for which you’ve prayed, you can use it to pray over things repeatedly, and you can write the
|
||||||
|
result when the answer comes <em>(or it was “no”)</em>.
|
||||||
|
<p>myPrayerJournal was borne of out of a personal desire
|
||||||
|
<a href=https://daniel.summershome.org target=_blank rel=noopener>Daniel</a> had to have something that would
|
||||||
|
help him with his prayer life. When it’s time to pray, it’s not really time to use an app, so the
|
||||||
|
design goal here is to keep it simple and unobtrusive. It will also help eliminate some of the downsides to a
|
||||||
|
paper prayer journal, like not remembering whether you’ve prayed for a request, or running out of room to
|
||||||
|
write another update on one.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Signing Up</h3>
|
||||||
|
<p>myPrayerJournal uses login services using Google or Microsoft accounts. The only information the application
|
||||||
|
stores in its database is your user ID token it receives from these services, so there are no permissions you
|
||||||
|
should have to accept from these provider other than establishing that you can log on with that account. Because
|
||||||
|
of this, you’ll want to pick the same one each time; the tokens between the two accounts are different,
|
||||||
|
even if you use the same e-mail address to log on to both.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Your Prayer Journal</h3>
|
||||||
|
<p>Your current requests will be presented in columns (usually three, but it could be more or less, depending on the
|
||||||
|
size of your screen or device). Each request is in its own card, and the buttons at the top of each card apply
|
||||||
|
to that request. The last line of each request also tells you how long it has been since anything has been done
|
||||||
|
on that request. Any time you see something like “a few minutes ago,” you can hover over that to see
|
||||||
|
the actual date/time the action was taken.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Adding a Request</h3>
|
||||||
|
<p>To add a request, click the “Add a New Request” button at the top of your journal. Then, enter the
|
||||||
|
text of the request as you see fit; there is no right or wrong way, and you are the only person who will see the
|
||||||
|
text you enter. When you save the request, it will go to the bottom of the list of requests.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Setting Request Recurrence</h3>
|
||||||
|
<p>When you add or update a request, you can choose whether requests go to the bottom of the journal once they have
|
||||||
|
been marked “Prayed” or whether they will reappear after a delay. You can set recurrence in terms of
|
||||||
|
hours, days, or weeks, but it cannot be longer than 365 days. If you decide you want a request to reappear
|
||||||
|
sooner, you can skip the current delay; click the “Active” menu link, find the request in the list
|
||||||
|
(likely near the bottom), and click the “Show Now” button.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Praying for Requests</h3>
|
||||||
|
<p>The first button for each request has a checkmark icon; clicking this button will mark the request as
|
||||||
|
“Prayed” and move it to the bottom of the list (or off, if you’ve set a recurrence period for
|
||||||
|
the request). This allows you, if you’re praying through your requests, to start at the top left (with the
|
||||||
|
request that it’s been the longest since you’ve prayed) and click the button as you pray; when the
|
||||||
|
request move below or away, the next-least-recently-prayed request will take the top spot.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Editing Requests</h3>
|
||||||
|
<p>The second button for each request has a pencil icon. This allows you to edit the text of the request, pretty
|
||||||
|
much the same way you entered it; it starts with the current text, and you can add to it, modify it, or
|
||||||
|
completely replace it. By default, updates will go in with an “Updated” status; you have the option
|
||||||
|
to also mark this update as “Prayed” or “Answered”. Answered requests will drop off the
|
||||||
|
journal list.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Adding Notes</h3>
|
||||||
|
<p>The third button for each request has an icon that looks like a speech bubble with lines on it; this lets you
|
||||||
|
record notes about the request. If there is something you want to record that doesn’t change the text of
|
||||||
|
the request, this is the place to do it. For example, you may be praying for a long-term health issue, and that
|
||||||
|
person tells you that their status is the same; or, you may want to record something God said to you while you
|
||||||
|
were praying for that request."
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Snoozing Requests</h3>
|
||||||
|
<p>There may be a time when a request does not need to appear. The fourth button, with the clock icon, allows you to
|
||||||
|
snooze requests until the day you specify. Additionally, if you have any snoozed requests, a
|
||||||
|
“Snoozed” menu item will appear next to the “Journal” one; this page allows you to see
|
||||||
|
what requests are snoozed, and return them to your journal by canceling the snooze.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Viewing a Request and Its History</h3>
|
||||||
|
<p>myPrayerJournal tracks all actions related to a request; from the “Active” and “Answered”
|
||||||
|
menu links (and “Snoozed”, if it’s showing), there is a “View Full Request”
|
||||||
|
button. That page will show the current text of the request; how many times it has been marked as prayed; how
|
||||||
|
long it has been an active request; and a log of all updates, prayers, and notes you have recorded. That log is
|
||||||
|
listed from most recent to least recent; if you want to read it chronologically, press the “End” key
|
||||||
|
on your keyboard and read it from the bottom up.
|
||||||
|
<p>The “Active” link will show all requests that have not yet been marked answered, including snoozed
|
||||||
|
and recurring requests. If requests are snoozed, or in a recurrence period off the journal, there will be a
|
||||||
|
button where you can return the request to the list (either “Cancel Snooze” or “Show
|
||||||
|
Now”). The “Answered” link shows all requests that have been marked answered. The
|
||||||
|
“Snoozed” link only shows snoozed requests.
|
||||||
|
|
||||||
|
<h3 class="mb-3 mt-4">Final Notes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>If you encounter errors, please
|
||||||
|
<a href=https://git.bitbadger.solutions/bit-badger/myPrayerJournal/issues target=_blank rel=noopener>file an
|
||||||
|
issue</a> (or <a href="mailto:daniel@bitbadger.solutions?subject=myPrayerJournal+Issue">e-mail Daniel</a> if
|
||||||
|
you do not have an account on that server) with as much detail as possible. You can also provide
|
||||||
|
suggestions, or browse the list of currently open issues.
|
||||||
|
<li>Prayer requests and their history are securely backed up nightly along with other Bit Badger Solutions data.
|
||||||
|
<li>Prayer changes things - most of all, the one doing the praying. I pray that this tool enables you to deepen
|
||||||
|
and strengthen your prayer life.
|
||||||
|
</ul>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
18
src/public/index.php
Normal file
18
src/public/index.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Layout;
|
||||||
|
|
||||||
|
require '../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Layout::pageHead('Welcome'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<p>
|
||||||
|
<p>myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them,
|
||||||
|
update them as God moves in the situation, and record a final answer received on that request. It also allows
|
||||||
|
individuals to review their answered prayers.
|
||||||
|
<p>This site is open and available for anyone who wants to use it. To get started, simply click the “Log
|
||||||
|
On” link above, and log on with either a Microsoft or Google account. You can also learn more about the
|
||||||
|
site at the “Docs” link, also above.
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
50
src/public/journal.php
Normal file
50
src/public/journal.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, Layout, UI};
|
||||||
|
|
||||||
|
require '../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Auth::requireUser();
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$name = $user['given_name'] ?? 'Your';
|
||||||
|
Layout::pageHead('Journal'); ?>
|
||||||
|
<article class="container-fluid mt-3">
|
||||||
|
<h2 class=pb-3><?=$name?><?=$name == 'Your' ? '' : '’s'?> Prayer Journal</h2>
|
||||||
|
<p class="pb-3 text-center"><?php
|
||||||
|
UI::pageLink('/request/edit?id=new', UI::icon('add_box') . ' Add a Prayer Request',
|
||||||
|
['class' => 'btn btn-primary']); ?>
|
||||||
|
<p hx-get=/components/journal-items hx-swap=outerHTML hx-trigger=load hx-target=this>
|
||||||
|
Loading your prayer journal…
|
||||||
|
<div id=notesModal class="modal fade" tabindex=-1 aria-labelledby=nodesModalLabel aria-hidden=true>
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
|
<div class=modal-content>
|
||||||
|
<div class=modal-header>
|
||||||
|
<h5 class=modal-title id=nodesModalLabel>Add Notes to Prayer Request</h5>
|
||||||
|
<button type=button class=btn-close data-bs-dismiss=modal aria-label=Close></button>
|
||||||
|
</div>
|
||||||
|
<div class=modal-body id=notesBody></div>
|
||||||
|
<div class=modal-footer>
|
||||||
|
<button type=button id=notesDismiss class="btn btn-secondary" data-bs-dismiss=modal>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id=snoozeModal class="modal fade" tabindex=-1 aria-labelledby=snoozeModalLabel aria-hidden=true>
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class=modal-content>
|
||||||
|
<div class=modal-header>
|
||||||
|
<h5 class=modal-title id=snoozeModalLabel>Snooze Prayer Request</h5>
|
||||||
|
<button type=button class=btn-close data-bs-dismiss=modal aria-label=Close></button>
|
||||||
|
</div>
|
||||||
|
<div class=modal-body id=snoozeBody></div>
|
||||||
|
<div class=modal-footer>
|
||||||
|
<button type=button id=snoozeDismiss class="btn btn-secondary" data-bs-dismiss=modal>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
|
|
||||||
72
src/public/legal/privacy-policy.php
Normal file
72
src/public/legal/privacy-policy.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Layout;
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Layout::pageHead('Privacy Policy'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<h2 class=mb-2>Privacy Policy</h2>
|
||||||
|
<h6 class="text-muted pb-3">as of May 21<sup>st</sup>, 2018</h6>
|
||||||
|
<p>The nature of the service is one where privacy is a must. The items below will help you understand the data we
|
||||||
|
collect, access, and store on your behalf as you use this service.
|
||||||
|
<div class=card>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>Third Party Services</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize
|
||||||
|
yourself with the privacy policy for
|
||||||
|
<a href=https://auth0.com/privacy target=_blank rel=noopener>Auth0</a>, as well as your chosen
|
||||||
|
provider
|
||||||
|
(<a href=https://privacy.microsoft.com/en-us/privacystatement target=_blank rel=noopener>Microsoft</a>
|
||||||
|
or <a href=https://policies.google.com/privacy target=_blank rel=noopener>Google</a>).
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>What We Collect</h3>
|
||||||
|
<h4>Identifying Data</h4>
|
||||||
|
<ul>
|
||||||
|
<li>The only identifying data myPrayerJournal stores is the subscriber (“sub”) field
|
||||||
|
from the token we receive from Auth0, once you have signed in through their hosted service. All
|
||||||
|
information is associated with you via this field.
|
||||||
|
<li>While you are signed in, within your browser, the service has access to your first and last
|
||||||
|
names, along with a URL to the profile picture (provided by your selected identity provider).
|
||||||
|
This information is not transmitted to the server, and is removed when “Log Off” is
|
||||||
|
clicked.
|
||||||
|
</ul>
|
||||||
|
<h4>User Provided Data</h4>
|
||||||
|
<ul class=mb-0>
|
||||||
|
<li>myPrayerJournal stores the information you provide, including the text of prayer requests,
|
||||||
|
updates, and notes; and the date/time when certain actions are taken.
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>How Your Data Is Accessed / Secured</h3>
|
||||||
|
<ul class=mb-0>
|
||||||
|
<li>Your provided data is returned to you, as required, to display your journal or your answered
|
||||||
|
requests. On the server, it is stored in a controlled-access database.
|
||||||
|
<li>Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling
|
||||||
|
manner; backups are preserved for the prior 7 days, and backups from the 1<sup>st</sup> and
|
||||||
|
15<sup>th</sup> are preserved for 3 months. These backups are stored in a private cloud data
|
||||||
|
repository.
|
||||||
|
<li>The data collected and stored is the absolute minimum necessary for the functionality of the
|
||||||
|
service. There are no plans to “monetize” this service, and storing the minimum
|
||||||
|
amount of information means that the data we have is not interesting to purchasers (or those who
|
||||||
|
may have more nefarious purposes).
|
||||||
|
<li>Access to servers and backups is strictly controlled and monitored for unauthorized access
|
||||||
|
attempts.
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>Removing Your Data</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
At any time, you may choose to discontinue using this service. Both Microsoft and Google provide
|
||||||
|
ways to revoke access from this application. However, if you want your data removed from the
|
||||||
|
database, please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to
|
||||||
|
doing so, to ensure we can determine which subscriber ID belongs to you.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
59
src/public/legal/terms-of-service.php
Normal file
59
src/public/legal/terms-of-service.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Layout, UI};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Layout::pageHead('Terms of Service'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<h2 class=mb-2>Terms of Service</h2>
|
||||||
|
<h6 class="text-muted pb-3">as of May 21<sup>st</sup>, 2018</h6>
|
||||||
|
<div class=card>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>1. Acceptance of Terms</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you
|
||||||
|
are responsible to ensure that your use of this site complies with all applicable laws. Your
|
||||||
|
continued use of this site implies your acceptance of these terms.
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>2. Description of Service and Registration</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It
|
||||||
|
requires no registration by itself, but access is granted based on a successful login with an
|
||||||
|
external identity provider. See
|
||||||
|
<?php UI::pageLink('/legal/privacy-policy', 'our privacy policy'); ?> for details on how that
|
||||||
|
information is accessed and stored.
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>3. Third Party Services</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
This service utilizes a third-party service provider for identity management. Review the terms of
|
||||||
|
service for <a href=https://auth0.com/terms target=_blank rel=noopener>Auth0</a>, as well as those
|
||||||
|
for the selected authorization provider
|
||||||
|
(<a href=https://www.microsoft.com/en-us/servicesagreement target=_blank rel=noopener>Microsoft</a>
|
||||||
|
or <a href=https://policies.google.com/terms target=_blank rel=noopener>Google</a>).
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>4. Liability</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
This service is provided “as is”, and no warranty (express or implied) exists. The
|
||||||
|
service and its developers may not be held liable for any damages that may arise through the use of
|
||||||
|
this service.
|
||||||
|
</div>
|
||||||
|
<div class=list-group-item>
|
||||||
|
<h3>5. Updates to Terms</h3>
|
||||||
|
<p class=card-text>
|
||||||
|
These terms and conditions may be updated at any time, and this service does not have the capability
|
||||||
|
to notify users when these change. The date at the top of the page will be updated when any of the
|
||||||
|
text of these terms is updated."
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class=pt-3>
|
||||||
|
You may also wish to review our <?php UI::pageLink('/legal/privacy-policy', 'privacy policy'); ?> to learn how
|
||||||
|
we handle your data.
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
15
src/public/request/cancel-snooze.php
Normal file
15
src/public/request/cancel-snooze.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Layout, Table, UI};
|
||||||
|
use BitBadger\PDODocument\RemoveFields;
|
||||||
|
|
||||||
|
require '../../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['GET'], false);
|
||||||
|
|
||||||
|
RemoveFields::byId(Table::REQUEST, $req->id, ['snoozedUntil']);
|
||||||
|
|
||||||
|
// TODO: message
|
||||||
|
Layout::bareHead();
|
||||||
|
UI::requestItem($req);
|
||||||
|
Layout::bareFoot();
|
||||||
105
src/public/request/edit.php
Normal file
105
src/public/request/edit.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, Layout, RecurrencePeriod, Request, RequestAction, UI};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Auth::requireUser();
|
||||||
|
|
||||||
|
$isNew = $_GET['id'] == 'new';
|
||||||
|
|
||||||
|
$req = match ($isNew) {
|
||||||
|
true => new Request('new'),
|
||||||
|
false => Request::byId($_GET['id'])
|
||||||
|
};
|
||||||
|
if (!$req) not_found();
|
||||||
|
|
||||||
|
$cancelLink = match (true) {
|
||||||
|
str_ends_with($_SERVER['HTTP_REFERER'] ?? '', 'active.php') => '/requests/active',
|
||||||
|
str_ends_with($_SERVER['HTTP_REFERER'] ?? '', 'snoozed.php') => '/requests/snoozed',
|
||||||
|
default => '/journal'
|
||||||
|
};
|
||||||
|
$action = $_GET['id'] == 'new' ? 'Add' : 'Edit';
|
||||||
|
|
||||||
|
Layout::pageHead("$action Prayer Request");?>
|
||||||
|
<article class=container>
|
||||||
|
<h2 class=pb-3><?=$action?> Prayer Request</h2>
|
||||||
|
<form <?=$isNew ? 'hx-post' : 'hx-patch'?>=/request/save hx-target=#top hx-push-url=true>
|
||||||
|
<input type=hidden name=requestId value=<?=$req->id?>>
|
||||||
|
<input type=hidden name=returnTo value=<?=$cancelLink?>>
|
||||||
|
<div class="form-floating pb-3">
|
||||||
|
<textarea id=requestText name=requestText class=form-control style="min-height: 8rem;"
|
||||||
|
placeholder="Enter the text of the request" autofocus required><?=$req->currentText()?></textarea>
|
||||||
|
<label for=requestText>Prayer Request</label>
|
||||||
|
</div><br><?php
|
||||||
|
if (!$isNew) { ?>
|
||||||
|
<div class=pb-3>
|
||||||
|
<label>Also Mark As</label><br>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input type=radio class=form-check-input id=sU name=status value=<?=RequestAction::Updated->value?>
|
||||||
|
checked>
|
||||||
|
<label for=sU>Updated</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input type=radio class=form-check-input id=sP name=status value=<?=RequestAction::Prayed->value?>>
|
||||||
|
<label for=sP>Prayed</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input type=radio class=form-check-input id=sA name=status
|
||||||
|
value=<?=RequestAction::Answered->value?>>
|
||||||
|
<label for=sA>Answered</label>
|
||||||
|
</div>
|
||||||
|
</div><?php
|
||||||
|
} ?>
|
||||||
|
<div class=row">
|
||||||
|
<div class="col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6">
|
||||||
|
<p><strong>Recurrence </strong> <em class=text-muted>After prayer, request reappears…</em>
|
||||||
|
<div class="d-flex flex-row flex-wrap justify-content-center align-items-center">
|
||||||
|
<div class="form-check mx-2">
|
||||||
|
<input type=radio class=form-check-input id=rI name=recurType
|
||||||
|
value=<?=RecurrencePeriod::Immediate->value?>
|
||||||
|
onclick="mpj.edit.toggleRecurrence(event)"<?php
|
||||||
|
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' checked'; ?>>
|
||||||
|
<label for=rI>Immediately</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mx-2">
|
||||||
|
<input type=radio class=form-check-input id=rO name=recurType value=Other
|
||||||
|
onclick="mpj.edit.toggleRecurrence(event)"<?php
|
||||||
|
if ($req->recurrence->period <> RecurrencePeriod::Immediate) echo ' checked'; ?>>
|
||||||
|
<label for=rO>Every…</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating mx-2">
|
||||||
|
<input type=number class=form-control id=recurCount name=recurCount placeholder=0 required
|
||||||
|
value=<?=$req->recurrence->interval ?? 0?> style="width:6rem;"<?php
|
||||||
|
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>>
|
||||||
|
<label for=recurCount>Count</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating mx-2">
|
||||||
|
<select class=form-control id=recurInterval name=recurInterval style="width:6rem;" required<?php
|
||||||
|
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>>
|
||||||
|
<option value=<?=RecurrencePeriod::Hours->value?><?php
|
||||||
|
if ($req->recurrence->period == RecurrencePeriod::Hours) echo ' selected'; ?>>
|
||||||
|
hours
|
||||||
|
</option>
|
||||||
|
<option value=<?=RecurrencePeriod::Days->value?><?php
|
||||||
|
if ($req->recurrence->period == RecurrencePeriod::Days) echo ' selected'; ?>>
|
||||||
|
days
|
||||||
|
</option>
|
||||||
|
<option value=<?=RecurrencePeriod::Weeks->value?><?php
|
||||||
|
if ($req->recurrence->period == RecurrencePeriod::Weeks) echo ' selected'; ?>>
|
||||||
|
weeks
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label for=recurInterval>Interval</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end pt-3">
|
||||||
|
<button class="btn btn-primary me-2" type=submit><?=UI::icon('save');?> Save</button><?php
|
||||||
|
UI::pageLink($cancelLink, UI::icon('arrow_back') . ' Cancel', ['class' => 'btn btn-secondary ms-2']); ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
45
src/public/request/full.php
Normal file
45
src/public/request/full.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{History, Layout, Note, RequestAction, UI};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['GET']);
|
||||||
|
|
||||||
|
$answered = $req->isAnswered() ? new DateTimeImmutable($req->history[0]->asOf) : null;
|
||||||
|
$prayed = sizeof(array_filter($req->history, fn(History $hist) => $hist->action == RequestAction::Prayed));
|
||||||
|
$daysOpen =
|
||||||
|
(($answered ?? new DateTimeImmutable('now'))->getTimestamp()
|
||||||
|
- (new DateTimeImmutable(end($req->history)->asOf))->getTimestamp()) / 86400;
|
||||||
|
|
||||||
|
$logs = array_merge(
|
||||||
|
array_map(fn(Note $note) => [new DateTimeImmutable($note->asOf), 'Notes', $note->text], $req->notes),
|
||||||
|
array_map(fn(History $hist) => [new DateTimeImmutable($hist->asOf), $hist->action->value, $hist->text ?? ''],
|
||||||
|
$req->history));
|
||||||
|
usort($logs, fn($a, $b) => $a[0] > $b[0] ? -1 : 1);
|
||||||
|
if ($req->isAnswered()) array_shift($logs);
|
||||||
|
|
||||||
|
Layout::pageHead('Full Request');?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<div class=card>
|
||||||
|
<h5 class=card-header>Full Prayer Request</h5>
|
||||||
|
<div class=card-body>
|
||||||
|
<h6 class="card-subtitle text-muted mb-2"><?php
|
||||||
|
if (!is_null($answered)) { ?>
|
||||||
|
Answered <?=$answered->format('F j, Y')?>
|
||||||
|
(<?=UI::formatDistance('now', $req->history[0]->asOf);?>) •<?php
|
||||||
|
} ?>
|
||||||
|
Prayed <?=number_format($prayed)?> times • Open <?=number_format($daysOpen)?> days
|
||||||
|
</h6>
|
||||||
|
<p class=card-text><?=htmlentities($req->currentText())?>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush"><?php
|
||||||
|
foreach ($logs as $log) { ?>
|
||||||
|
<li class=list-group-item>
|
||||||
|
<p class=m-0><?=$log[1]?> <small><em><?=$log[0]->format('F j, Y')?></em></small><?php
|
||||||
|
if ($log[2] <> '') echo '<p class="mt-2 mb-0">' . htmlentities($log[2]);
|
||||||
|
} ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
14
src/public/request/note.php
Normal file
14
src/public/request/note.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Note, Table};
|
||||||
|
use BitBadger\PDODocument\Patch;
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['POST'], false);
|
||||||
|
|
||||||
|
array_unshift($req->notes, new Note((new DateTimeImmutable('now'))->format('c'), $_POST['notes']));
|
||||||
|
Patch::byId(Table::REQUEST, $req->id, ['notes' => $req->notes]);
|
||||||
|
|
||||||
|
hide_modal('notes');
|
||||||
|
http_response_code(202);
|
||||||
19
src/public/request/prayed.php
Normal file
19
src/public/request/prayed.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{History, RecurrencePeriod, RequestAction, Table, UI};
|
||||||
|
use BitBadger\PDODocument\Patch;
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['PATCH'], false);
|
||||||
|
$now = new DateTimeImmutable('now');
|
||||||
|
|
||||||
|
array_unshift($req->history, new History($now->format('c'), RequestAction::Prayed));
|
||||||
|
$patch = ['history' => $req->history];
|
||||||
|
|
||||||
|
if ($req->recurrence->period <> RecurrencePeriod::Immediate) {
|
||||||
|
$patch['showAfter'] = $now->add($req->recurrence->interval())->format('c');
|
||||||
|
}
|
||||||
|
Patch::byId(Table::REQUEST, $req->id, $patch);
|
||||||
|
|
||||||
|
UI::journal();
|
||||||
46
src/public/request/save.php
Normal file
46
src/public/request/save.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, History, Recurrence, RecurrencePeriod, Request, RequestAction, Table};
|
||||||
|
use BitBadger\PDODocument\{Document, Patch, RemoveFields};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'POST' && $_SERVER['REQUEST_METHOD'] <> 'PATCH') not_found();
|
||||||
|
|
||||||
|
Auth::requireUser(false);
|
||||||
|
|
||||||
|
$now = new DateTimeImmutable('now');
|
||||||
|
$recurrence = new Recurrence(RecurrencePeriod::from($_POST['recurType'] ?? $_PATCH['recurType']));
|
||||||
|
if ($recurrence->period <> RecurrencePeriod::Immediate) {
|
||||||
|
$recurrence->interval = (int)($_POST['recurCount'] ?? $_PATCH['recurCount']);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($_SERVER['REQUEST_METHOD']) {
|
||||||
|
case 'POST':
|
||||||
|
Document::insert(Table::REQUEST, new Request(
|
||||||
|
enteredOn: $now->format('c'),
|
||||||
|
userId: $_SESSION['user_id'],
|
||||||
|
recurrence: $recurrence,
|
||||||
|
history: [new History($now->format('c'), RequestAction::Created, $_POST['requestText'])]));
|
||||||
|
//Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||||
|
see_other('/journal');
|
||||||
|
|
||||||
|
case 'PATCH':
|
||||||
|
$req = Request::byId($_PATCH['requestId']);
|
||||||
|
if (!$req) not_found();
|
||||||
|
$patch = [];
|
||||||
|
// update recurrence if changed
|
||||||
|
if ($recurrence != $req->recurrence) {
|
||||||
|
$patch['recurrence'] = $recurrence;
|
||||||
|
if ($recurrence->period == RecurrencePeriod::Immediate) {
|
||||||
|
RemoveFields::byId(Table::REQUEST, $req->id, ['showAfter']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// append history
|
||||||
|
$upd8Text = trim($_PATCH['requestText']);
|
||||||
|
$text = $upd8Text == '' || $upd8Text == $req->currentText() ? null : $upd8Text;
|
||||||
|
array_unshift($req->history, new History($now->format('c'), RequestAction::from($_PATCH['status']), $text));
|
||||||
|
$patch['history'] = $req->history;
|
||||||
|
Patch::byId(Table::REQUEST, $req->id, $patch);
|
||||||
|
//Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl
|
||||||
|
see_other($_PATCH['returnTo']);
|
||||||
|
}
|
||||||
17
src/public/request/snooze.php
Normal file
17
src/public/request/snooze.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Table, UI};
|
||||||
|
use BitBadger\PDODocument\Patch;
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
$req = validate_request($_GET['id'], ['PATCH'], false);
|
||||||
|
|
||||||
|
$until = (new DateTimeImmutable($_PATCH['until'] . 'T00:00:00', new DateTimeZone($_REQUEST['time_zone'])))
|
||||||
|
->setTimezone(new DateTimeZone('Etc/UTC'));
|
||||||
|
Patch::byId(Table::REQUEST, $req->id, ['snoozedUntil' => $until->format('c')]);
|
||||||
|
|
||||||
|
// TODO: message
|
||||||
|
|
||||||
|
hide_modal('snooze');
|
||||||
|
UI::journal();
|
||||||
22
src/public/requests/active.php
Normal file
22
src/public/requests/active.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, Layout, Request, UI};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Auth::requireUser();
|
||||||
|
|
||||||
|
$reqs = Request::active();
|
||||||
|
|
||||||
|
Layout::pageHead('Active Requests'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<h2 class=pb-3>Active Requests</h2><?php
|
||||||
|
if ($reqs->hasItems()) {
|
||||||
|
UI::requestList($reqs);
|
||||||
|
} else {
|
||||||
|
UI::noResults('No Active Requests', '/journal', 'Return to your journal',
|
||||||
|
'Your prayer journal has no active requests');
|
||||||
|
} ?>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
24
src/public/requests/answered.php
Normal file
24
src/public/requests/answered.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, Layout, Request, UI};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Auth::requireUser();
|
||||||
|
|
||||||
|
$reqs = Request::answered();
|
||||||
|
|
||||||
|
Layout::pageHead('Answered Requests'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<h2 class=pb-3>Answered Requests</h2><?php
|
||||||
|
if ($reqs->hasItems()) {
|
||||||
|
UI::requestList($reqs);
|
||||||
|
} else {
|
||||||
|
UI::noResults('No Answered Requests', '/journal', 'Return to your journal', <<<'TEXT'
|
||||||
|
Your prayer journal has no answered requests; once you have marked one as “Answered”, it will
|
||||||
|
appear here
|
||||||
|
TEXT);
|
||||||
|
} ?>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
22
src/public/requests/snoozed.php
Normal file
22
src/public/requests/snoozed.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\{Auth, Layout, Request, UI};
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
|
||||||
|
Auth::requireUser();
|
||||||
|
|
||||||
|
$reqs = Request::snoozed();
|
||||||
|
|
||||||
|
Layout::pageHead('Snoozed Requests'); ?>
|
||||||
|
<article class="container mt-3">
|
||||||
|
<h2 class=pb-3>Snoozed Requests</h2><?php
|
||||||
|
if ($reqs->hasItems()) {
|
||||||
|
UI::requestList($reqs);
|
||||||
|
} else {
|
||||||
|
UI::noResults('No Snoozed Requests', '/journal', 'Return to your journal',
|
||||||
|
'Your prayer journal has no snoozed requests');
|
||||||
|
} ?>
|
||||||
|
</article><?php
|
||||||
|
Layout::pageFoot();
|
||||||
7
src/public/script/bootstrap.bundle.min.js
vendored
Normal file
7
src/public/script/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/public/script/htmx.min.js
vendored
Normal file
1
src/public/script/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
104
src/public/script/mpj.js
Normal file
104
src/public/script/mpj.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use strict"
|
||||||
|
|
||||||
|
/** myPrayerJournal script */
|
||||||
|
this.mpj = {
|
||||||
|
/**
|
||||||
|
* Show a message via toast
|
||||||
|
* @param {string} message The message to show
|
||||||
|
*/
|
||||||
|
showToast (message) {
|
||||||
|
const [level, msg] = message.split("|||")
|
||||||
|
|
||||||
|
let header
|
||||||
|
if (level !== "success") {
|
||||||
|
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
|
||||||
|
|
||||||
|
header = document.createElement("div")
|
||||||
|
header.className = "toast-header"
|
||||||
|
header.innerHTML = heading(level === "warning" ? level : "error")
|
||||||
|
|
||||||
|
const close = document.createElement("button")
|
||||||
|
close.type = "button"
|
||||||
|
close.className = "btn-close"
|
||||||
|
close.setAttribute("data-bs-dismiss", "toast")
|
||||||
|
close.setAttribute("aria-label", "Close")
|
||||||
|
header.appendChild(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement("div")
|
||||||
|
body.className = "toast-body"
|
||||||
|
body.innerText = msg
|
||||||
|
|
||||||
|
const toastEl = document.createElement("div")
|
||||||
|
toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white`
|
||||||
|
toastEl.setAttribute("role", "alert")
|
||||||
|
toastEl.setAttribute("aria-live", "assertlive")
|
||||||
|
toastEl.setAttribute("aria-atomic", "true")
|
||||||
|
toastEl.addEventListener("hidden.bs.toast", e => e.target.remove())
|
||||||
|
if (header) toastEl.appendChild(header)
|
||||||
|
|
||||||
|
toastEl.appendChild(body)
|
||||||
|
document.getElementById("toasts").appendChild(toastEl)
|
||||||
|
new bootstrap.Toast(toastEl, { autohide: level === "success" }).show()
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Load local version of Bootstrap CSS if the CDN load failed
|
||||||
|
*/
|
||||||
|
ensureCss () {
|
||||||
|
let loaded = false
|
||||||
|
for (let i = 0; !loaded && i < document.styleSheets.length; i++) {
|
||||||
|
loaded = document.styleSheets[i].href.endsWith("bootstrap.min.css")
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
const css = document.createElement("link")
|
||||||
|
css.rel = "stylesheet"
|
||||||
|
css.href = "/style/bootstrap.min.css"
|
||||||
|
document.getElementsByTagName("head")[0].appendChild(css)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Script for the request edit component */
|
||||||
|
edit: {
|
||||||
|
/**
|
||||||
|
* Toggle the recurrence input fields
|
||||||
|
* @param {Event} e The click event
|
||||||
|
*/
|
||||||
|
toggleRecurrence ({ target }) {
|
||||||
|
const isDisabled = target.value === "Immediate"
|
||||||
|
;["recurCount", "recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The time zone of the current browser
|
||||||
|
* @type {string}
|
||||||
|
**/
|
||||||
|
timeZone: undefined,
|
||||||
|
/**
|
||||||
|
* Derive the time zone from the current browser
|
||||||
|
*/
|
||||||
|
deriveTimeZone () {
|
||||||
|
try {
|
||||||
|
this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||||
|
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||||
|
// Show a message if there was one in the response
|
||||||
|
if (hdrs.indexOf("x-toast") >= 0) {
|
||||||
|
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
||||||
|
}
|
||||||
|
// Hide a modal window if requested
|
||||||
|
if (hdrs.indexOf("x-hide-modal") >= 0) {
|
||||||
|
document.getElementById(evt.detail.xhr.getResponseHeader("x-hide-modal") + "Dismiss").click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
htmx.on("htmx:configRequest", function (evt) {
|
||||||
|
// Send the user's current time zone so that we can display local time
|
||||||
|
if (mpj.timeZone) {
|
||||||
|
evt.detail.headers["X-Time-Zone"] = mpj.timeZone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mpj.deriveTimeZone()
|
||||||
57
src/public/style/style.css
Normal file
57
src/public/style/style.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
nav {
|
||||||
|
background-color: green;
|
||||||
|
}
|
||||||
|
nav .m {
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
nav .p {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
nav .j {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.nav-item a:link,
|
||||||
|
.nav-item a:visited {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
margin: 0 .5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-item a:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: rgba(255, 255, 255, .2);
|
||||||
|
}
|
||||||
|
.nav-item a.is-active-route {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-top: solid 4px rgba(255, 255, 255, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.action-cell .material-icons {
|
||||||
|
font-size: 1.1rem ;
|
||||||
|
}
|
||||||
|
.material-icons {
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
#toastHost {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.request-text {
|
||||||
|
white-space: pre-line
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: solid 1px lightgray;
|
||||||
|
margin: 1rem -1rem 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
7
src/public/user/log-off.php
Normal file
7
src/public/user/log-off.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Auth;
|
||||||
|
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
Auth::logOff();
|
||||||
8
src/public/user/log-on.php
Normal file
8
src/public/user/log-on.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Auth;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||||
|
require '../../start.php';
|
||||||
|
|
||||||
|
Auth::logOn();
|
||||||
11
src/public/user/log-on/success.php
Normal file
11
src/public/user/log-on/success.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use MyPrayerJournal\Auth;
|
||||||
|
|
||||||
|
require '../../../start.php';
|
||||||
|
|
||||||
|
Auth::client()->exchange($_ENV['AUTH0_BASE_URL'] . '/user/log-on/success');
|
||||||
|
|
||||||
|
// TODO: get the possible redirect URL
|
||||||
|
header('Location: /journal');
|
||||||
|
exit();
|
||||||
85
src/start.php
Normal file
85
src/start.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
use Auth0\SDK\Exception\ConfigurationException;
|
||||||
|
use BitBadger\PDODocument\{Configuration, Definition, DocumentException, Mode};
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
use MyPrayerJournal\{Auth, Request, Table};
|
||||||
|
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
/** The version of this application */
|
||||||
|
const MPJ_VERSION = '4.0.0-alpha1';
|
||||||
|
|
||||||
|
(Dotenv::createImmutable(__DIR__))->load();
|
||||||
|
|
||||||
|
if (php_sapi_name() != 'cli') {
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$auth0_user = Auth::user();
|
||||||
|
if (!is_null($auth0_user)) {
|
||||||
|
$_SESSION['user_id'] = $auth0_user['sub'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$_REQUEST['time_zone'] = $_SERVER['HTTP_X_TIME_ZONE'] ?? 'Etc/UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration::$pdoDSN = 'sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', 'mpj.db']);
|
||||||
|
Configuration::$mode = Mode::SQLite;
|
||||||
|
Definition::ensureTable(Table::REQUEST);
|
||||||
|
Definition::ensureFieldIndex(Table::REQUEST, 'user', ['userId']);
|
||||||
|
|
||||||
|
$_PATCH = [];
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] ?? '' == 'PATCH') parse_str(file_get_contents('php://input'), $_PATCH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a 404 and exit
|
||||||
|
*/
|
||||||
|
function not_found(): never
|
||||||
|
{
|
||||||
|
http_response_code(404);
|
||||||
|
die('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a 303 redirect ("see other" - redirects to a GET)
|
||||||
|
*
|
||||||
|
* @param string $url The URL to which the browser should be redirected
|
||||||
|
*/
|
||||||
|
function see_other(string $url): never
|
||||||
|
{
|
||||||
|
header('Location: ' . (str_starts_with($url, 'http') ? '/' : $url));
|
||||||
|
http_response_code(303);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a header that instructs the browser to close an open modal dialog
|
||||||
|
*
|
||||||
|
* @param string $name The name of the dialog to be closed
|
||||||
|
*/
|
||||||
|
function hide_modal(string $name): void
|
||||||
|
{
|
||||||
|
header("X-Hide-Modal: $name");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the user, HTTP method, and request
|
||||||
|
*
|
||||||
|
* @param string $id The ID of the prayer request to retrieve
|
||||||
|
* @param array $methods The allowable HTTP methods
|
||||||
|
* @param bool $redirect Whether to redirect not-logged-on users (optional, defaults to true)
|
||||||
|
* @return Request The request (failures will not return)
|
||||||
|
* @throws ConfigurationException If any is encountered
|
||||||
|
* @throws DocumentException If any is encountered
|
||||||
|
*/
|
||||||
|
function validate_request(string $id, array $methods, bool $redirect = true): Request
|
||||||
|
{
|
||||||
|
if (sizeof(array_filter($methods, fn($it) => $_SERVER['REQUEST_METHOD'] == $it)) == 0) not_found();
|
||||||
|
|
||||||
|
Auth::requireUser($redirect);
|
||||||
|
|
||||||
|
$req = Request::byId($id);
|
||||||
|
if (!$req) not_found();
|
||||||
|
|
||||||
|
return $req;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user