First cut of Add / Edit / Active / Answered pages
- Centralized UI between UI and Layout classes
This commit is contained in:
@@ -19,7 +19,7 @@ class History implements JsonSerializable
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$values = ['asOf' => $this->asOf, 'action' => $this->action->value];
|
||||
if (!is_null($this->text)) $values['text'] = $this->text;
|
||||
if (isset($this->text)) $values['text'] = $this->text;
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ class Layout
|
||||
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
|
||||
*
|
||||
@@ -27,7 +37,7 @@ class Layout
|
||||
*/
|
||||
public static function htmlHead(string $title): void
|
||||
{
|
||||
if (is_htmx()) {
|
||||
if (self::isHtmx()) {
|
||||
echo "<!DOCTYPE html><html lang=en><head lang=en><title>$title « myPrayerJournal</title></head>";
|
||||
} else {
|
||||
echo <<<HEAD
|
||||
@@ -36,6 +46,7 @@ class Layout
|
||||
<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"
|
||||
@@ -130,4 +141,30 @@ class Layout
|
||||
</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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class Note
|
||||
* @param string $text The text of the note
|
||||
*/
|
||||
public function __construct(public string $asOf, public string $text) { }
|
||||
// AFU2SCY5X2BNVRXP6W47D369
|
||||
|
||||
/**
|
||||
* Retrieve notes for a given request
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use DateInterval;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
@@ -15,10 +16,26 @@ class Recurrence implements JsonSerializable
|
||||
*/
|
||||
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 (!is_null($this->interval)) $values['interval'] = $this->interval;
|
||||
if (isset($this->interval)) $values['interval'] = $this->interval;
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,16 @@ class Request implements JsonSerializable
|
||||
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;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$values = [
|
||||
@@ -66,8 +76,8 @@ class Request implements JsonSerializable
|
||||
'history' => $this->history,
|
||||
'notes' => $this->notes
|
||||
];
|
||||
if (!is_null($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
|
||||
if (!is_null($this->showAfter)) $values['showAfter'] = $this->showAfter;
|
||||
if (isset($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
|
||||
if (isset($this->showAfter)) $values['showAfter'] = $this->showAfter;
|
||||
return $values;
|
||||
}
|
||||
|
||||
@@ -107,4 +117,53 @@ class Request implements JsonSerializable
|
||||
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
|
||||
* @return DocumentList<Request> The requests matching the criteria
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
private static function forUser(bool $active = true): DocumentList
|
||||
{
|
||||
$table = Table::REQUEST;
|
||||
$op = $active ? '<>' : '=';
|
||||
$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'
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use BitBadger\PDODocument\DocumentException;
|
||||
use BitBadger\PDODocument\DocumentList;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
@@ -20,6 +22,60 @@ class UI
|
||||
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-notes?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
|
||||
*/
|
||||
@@ -76,4 +132,42 @@ class UI
|
||||
}
|
||||
}
|
||||
|
||||
public static function requestList(DocumentList $reqs): void
|
||||
{
|
||||
$btnClass = "btn btn-light mx-2";
|
||||
/// Create a request within the list
|
||||
/* let reqListItem now tz req =
|
||||
let isFuture instant = defaultArg (instant |> Option.map (fun it -> it > now)) false
|
||||
let reqId = RequestId.toString req.RequestId
|
||||
let isSnoozed = isFuture req.SnoozedUntil
|
||||
let isPending = (not isSnoozed) && isFuture req.ShowAfter
|
||||
let restoreBtn (link : string) title =
|
||||
button [ btnClass; _hxPatch $"/request/{reqId}/{link}"; _title title ] [ icon "restore" ] */ ?>
|
||||
<div class=list-group><?php
|
||||
foreach ($reqs->items() as /** @var Request $req */ $req) { ?>
|
||||
<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 isSnoozed then restoreBtn "cancel-snooze" "Cancel Snooze"
|
||||
// elif isPending then restoreBtn "show" "Show Now"
|
||||
|
||||
echo '<p class="request-text mb-0">' . $req->currentText();
|
||||
// if isSnoozed || isPending || isAnswered then
|
||||
// br []
|
||||
// small [ _class "text-muted" ] [
|
||||
// if isSnoozed then [ str "Snooze expires "; relativeDate req.SnoozedUntil.Value now tz ]
|
||||
// elif isPending then [ str "Request appears next "; relativeDate req.ShowAfter.Value now tz ]
|
||||
// else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now tz ]
|
||||
// |> em []
|
||||
// ]
|
||||
?>
|
||||
</div><?php
|
||||
} ?>
|
||||
</div><?php
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user