aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Digitigrade/CharacterRange.php17
-rw-r--r--Digitigrade/Model/Actor.php4
-rw-r--r--Digitigrade/Model/BlockRelation.php2
-rw-r--r--Digitigrade/Model/FollowRelation.php2
-rw-r--r--Digitigrade/Model/Interaction.php4
-rw-r--r--Digitigrade/Model/Note.php52
-rw-r--r--Digitigrade/Notification/PendingFollowActionedNotif.php2
-rw-r--r--Digitigrade/Notification/UnblockNotif.php2
-rw-r--r--Digitigrade/Notification/UnfollowNotif.php2
-rw-r--r--Digitigrade/Page.php75
-rw-r--r--Digitigrade/PageLink.php15
-rw-r--r--locale/en_GB.json10
-rw-r--r--misc/format_datetime.php8
-rw-r--r--routes/lookup.php6
-rw-r--r--routes/note.php5
-rw-r--r--routes/pages.php76
-rw-r--r--static/form.css9
-rw-r--r--static/icons.css3
-rw-r--r--static/misc.css16
-rw-r--r--static/note.css5
-rw-r--r--static/pages.css97
-rw-r--r--static/style.css1
-rw-r--r--static/theme.css1
-rw-r--r--templates/formatting_hint.php5
-rw-r--r--templates/left_side_navigation.php4
-rw-r--r--templates/note.php19
-rw-r--r--templates/note_page.php36
-rw-r--r--templates/pages_list_item.php11
-rw-r--r--templates/pages_list_page.php19
-rw-r--r--templates/pages_new_form.php26
-rw-r--r--templates/pages_new_page.php4
-rw-r--r--templates/reply_form.php6
-rw-r--r--templates/write_note_form.php6
33 files changed, 518 insertions, 32 deletions
diff --git a/Digitigrade/CharacterRange.php b/Digitigrade/CharacterRange.php
new file mode 100644
index 0000000..b2d2968
--- /dev/null
+++ b/Digitigrade/CharacterRange.php
@@ -0,0 +1,17 @@
+<?php
+namespace Digitigrade;
+
+class CharacterRange implements \JsonSerializable {
+ public int $start;
+ public int $end;
+
+ public function __construct(string $range) {
+ $parts = explode('-', $range, 2);
+ $this->start = +$parts[0];
+ $this->end = +$parts[1];
+ }
+
+ public function jsonSerialize(): string {
+ return "$this->start-$this->end";
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/Actor.php b/Digitigrade/Model/Actor.php
index 229582c..0541e1b 100644
--- a/Digitigrade/Model/Actor.php
+++ b/Digitigrade/Model/Actor.php
@@ -325,6 +325,10 @@ class Actor extends PushableModel implements RpcReceiver {
return $this->handle . (($alwaysIncludeDomain || !$this->isLocal) ? '@' . hostname_from_uri($this->uri) : '');
}
+ public function getLocalUiHref(): string {
+ return '/@/' . $this->getFullHandle();
+ }
+
public function jsonSerialize(): array {
if ($this->deleted) {
return [
diff --git a/Digitigrade/Model/BlockRelation.php b/Digitigrade/Model/BlockRelation.php
index ca7166c..0b9054a 100644
--- a/Digitigrade/Model/BlockRelation.php
+++ b/Digitigrade/Model/BlockRelation.php
@@ -62,7 +62,7 @@ class BlockRelation extends Model implements Notifyable {
return sprintf(__("notifications.block"), $this->subject->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->subject->getFullHandle();
+ return $this->subject->getLocalUiHref();
}
public function getNotificationImageUrl(): ?string {
return $this->subject->avatar ?? '/static/default-avatar.png';
diff --git a/Digitigrade/Model/FollowRelation.php b/Digitigrade/Model/FollowRelation.php
index ecb625a..066fc47 100644
--- a/Digitigrade/Model/FollowRelation.php
+++ b/Digitigrade/Model/FollowRelation.php
@@ -66,7 +66,7 @@ class FollowRelation extends Model implements Notifyable {
return sprintf(__("notifications.follow.$status"), $this->subject->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->subject->getFullHandle();
+ return $this->subject->getLocalUiHref();
}
public function getNotificationImageUrl(): ?string {
return $this->subject->avatar ?? '/static/default-avatar.png';
diff --git a/Digitigrade/Model/Interaction.php b/Digitigrade/Model/Interaction.php
index 43d02a2..dd977fd 100644
--- a/Digitigrade/Model/Interaction.php
+++ b/Digitigrade/Model/Interaction.php
@@ -97,7 +97,7 @@ class Interaction extends PushableModel implements Notifyable {
return sprintf(__('notifications.interaction.' . $this->kind->value . '.title'), $this->author->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->target->author->getFullHandle() . '/note/' . $this->target->id;
+ return $this->target->getLocalUiHref();
}
public function getNotificationBody(): ?string {
return isset($this->target->summary) ? ('[' . $this->target->summary . ']') : $this->target->plainContent;
@@ -106,7 +106,7 @@ class Interaction extends PushableModel implements Notifyable {
return $this->author->avatar ?? '/static/default-avatar.png';
}
public function getNotificationImageLink(): ?string {
- return '/@/' . $this->author->getFullHandle();
+ return $this->author->getLocalUiHref();
}
public function toJsonReference(): mixed {
diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php
index 880e3e8..b32e625 100644
--- a/Digitigrade/Model/Note.php
+++ b/Digitigrade/Model/Note.php
@@ -7,11 +7,13 @@ use Digitigrade\FormattingProvider\SanitisedHtml;
use Digitigrade\LinkPreview;
use Digitigrade\Model\HomeTimelineItem;
use Digitigrade\Notification\Notifyable;
+use Digitigrade\Page;
use Digitigrade\Timeline\TimelineIncludeable;
use Digitigrade\Timeline\TimelineItem;
class Note extends PushableModel implements TimelineIncludeable, Notifyable {
public const EXTENSION_LINK_PREVIEWS = 'https://pawpub.entities.org.uk/extension/link-previews';
+ public const EXTENSION_PAGES = 'https://pawpub.entities.org.uk/extension/pages';
public ?int $id;
public string $uri;
@@ -195,6 +197,29 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
return $notes;
}
+ public static function findAllWithExtension(string $extensionUri, ?int $limit = null, int $offset = 0): array {
+ $pdo = Db::getInstance()->getPdo();
+ $query = 'SELECT note_id FROM note_extension LEFT JOIN note ON note.id = note_id '
+ . 'WHERE note_extension.uri = ? ORDER BY note.created DESC';
+ if ($limit != null) {
+ $query .= " LIMIT $limit OFFSET $offset";
+ }
+ $stmt = $pdo->prepare($query);
+ $stmt->execute([$extensionUri]);
+ $notes = [];
+ while ($id = $stmt->fetchColumn(0)) {
+ $notes[] = self::find($id);
+ }
+ return $notes;
+ }
+
+ public static function countWithExtension(string $extensionUri): int {
+ $pdo = Db::getInstance()->getPdo();
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM note_extension WHERE uri = ?');
+ $stmt->execute([$extensionUri]);
+ return $stmt->fetchColumn(0);
+ }
+
public function getInteractions(): array {
return Interaction::findAllWithTarget($this);
}
@@ -268,6 +293,10 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
return htmlspecialchars($this->plainContent);
}
+ public function getLocalUiHref(bool $asPage = false): string {
+ return '/@/' . $this->author->getFullHandle() . ($asPage ? '/page/' : '/note/') . $this->id;
+ }
+
/**
* Gets a list of LinkPreviews applicable to this note
* @return LinkPreview[]
@@ -285,6 +314,25 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
}
/**
+ * @return bool whether this note has an attached page
+ */
+ public function hasPage(): bool {
+ return isset($this->extensions[self::EXTENSION_PAGES]);
+ }
+
+ public function getPage(): ?Page {
+ if (!$this->hasPage())
+ return null;
+ $mapper = new \JsonMapper();
+ $mapper->bStrictObjectTypeChecking = false; // so that CharacterRanges can be decoded
+ return $mapper->map($this->extensions[self::EXTENSION_PAGES], Page::class);
+ }
+
+ public function setPage(Page $page) {
+ $this->extensions[self::EXTENSION_PAGES] = json_decode(json_encode($page));
+ }
+
+ /**
* Generates new LinkPreviews for this note (but does not save them automatically)
* @return LinkPreview[] the new link previews
*/
@@ -324,7 +372,7 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
return sprintf(__("notifications.$action.title"), $this->author->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->author->getFullHandle() . '/note/' . $this->id;
+ return $this->author->getLocalUiHref();
}
public function getNotificationBody(): ?string {
return isset($this->summary) ? "[$this->summary]" : $this->plainContent;
@@ -333,7 +381,7 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
return $this->author->avatar ?? '/static/default-avatar.png';
}
public function getNotificationImageLink(): ?string {
- return '/@/' . $this->author->getFullHandle();
+ return $this->author->getLocalUiHref();
}
public function processNotifications() {
diff --git a/Digitigrade/Notification/PendingFollowActionedNotif.php b/Digitigrade/Notification/PendingFollowActionedNotif.php
index 6ab36ee..080fd45 100644
--- a/Digitigrade/Notification/PendingFollowActionedNotif.php
+++ b/Digitigrade/Notification/PendingFollowActionedNotif.php
@@ -32,7 +32,7 @@ class PendingFollowActionedNotif implements Notifyable {
return sprintf(__("notifications.follow.$this->action"), $this->subject->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->subject->getFullHandle();
+ return $this->subject->getLocalUiHref();
}
public function getNotificationImageUrl(): ?string {
return $this->subject->avatar ?? '/static/default-avatar.png';
diff --git a/Digitigrade/Notification/UnblockNotif.php b/Digitigrade/Notification/UnblockNotif.php
index 7f7ac64..708064d 100644
--- a/Digitigrade/Notification/UnblockNotif.php
+++ b/Digitigrade/Notification/UnblockNotif.php
@@ -29,7 +29,7 @@ class UnblockNotif implements Notifyable {
return sprintf(__('notifications.unblock'), $this->subject->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->subject->getFullHandle();
+ return $this->subject->getLocalUiHref();
}
public function getNotificationImageUrl(): ?string {
return $this->subject->avatar ?? '/static/default-avatar.png';
diff --git a/Digitigrade/Notification/UnfollowNotif.php b/Digitigrade/Notification/UnfollowNotif.php
index e00c342..a131dab 100644
--- a/Digitigrade/Notification/UnfollowNotif.php
+++ b/Digitigrade/Notification/UnfollowNotif.php
@@ -29,7 +29,7 @@ class UnfollowNotif implements Notifyable {
return sprintf(__('notifications.follow.removed'), $this->subject->displayName);
}
public function getNotificationTitleLink(): ?string {
- return '/@/' . $this->subject->getFullHandle();
+ return $this->subject->getLocalUiHref();
}
public function getNotificationImageUrl(): ?string {
return $this->subject->avatar ?? '/static/default-avatar.png';
diff --git a/Digitigrade/Page.php b/Digitigrade/Page.php
new file mode 100644
index 0000000..963a27d
--- /dev/null
+++ b/Digitigrade/Page.php
@@ -0,0 +1,75 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\FormattingProvider\CommonMark;
+use Digitigrade\FormattingProvider\SanitisedHtml;
+use Digitigrade\Model\Note;
+
+class Page implements \JsonSerializable {
+ public string $title;
+ public array $content;
+ /** @var PageLink[] */
+ public array $links;
+
+ public static function create(string $title, array $content) {
+ $page = new self();
+ $page->title = $title;
+ $page->content = $content;
+ $page->links = [];
+ return $page;
+ }
+
+ /**
+ * Gets the best available formatted content (or plain if there aren't any)
+ * and converts it to sanitised HTML
+ * @return string HTML that is hopefully safe to include directly
+ */
+ public function getBestContentAsHtml(): string {
+ if (isset($this->content['text/html'])) {
+ return SanitisedHtml::getInstance()->renderToHtml($this->insertLinks($this->content['text/html'], 'text/html'));
+ } elseif (isset($this->content['text/markdown'])) {
+ return CommonMark::getInstance()->renderToHtml($this->insertLinks($this->content['text/markdown'], 'text/markdown'));
+ }
+ return htmlspecialchars($this->insertLinks($this->content['text/plain'] ?? __('placeholder'), 'text/plain'));
+ }
+
+ private function insertLinks(string $originalContent, string $mediaType): string {
+ $result = $originalContent;
+ $offset = 0;
+
+ foreach ($this->links as $link) {
+ $range = $link->range[$mediaType] ?? null;
+ $targetNote = Note::findByUri($link->target);
+ if ($range == null || $targetNote == null)
+ continue;
+ $target = path_to_uri(htmlspecialchars($targetNote->getLocalUiHref(true)));
+ $before = mb_substr($result, 0, $range->start, 'utf-8');
+ $label = mb_substr($result, $range->start, $range->end - $range->start, 'utf-8');
+ $after = mb_substr($result, $range->end, null, 'utf-8');
+ $startTag = match ($mediaType) {
+ 'text/html' => '<a href="' . htmlspecialchars($target) . '">',
+ 'text/markdown' => '[',
+ 'text/plain' => '',
+ default => ''
+ };
+ $endTag = match ($mediaType) {
+ 'text/html' => '</a>',
+ 'text/markdown' => '](' . htmlspecialchars($target) . ')',
+ 'text/plain' => " ($target)",
+ default => ''
+ };
+ $result = $before . $startTag . $label . $endTag . $after;
+ $offset += strlen($startTag) + strlen($endTag);
+ }
+
+ return $result;
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'title' => $this->title,
+ 'content' => $this->content,
+ 'links' => $this->links
+ ];
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/PageLink.php b/Digitigrade/PageLink.php
new file mode 100644
index 0000000..3b8e656
--- /dev/null
+++ b/Digitigrade/PageLink.php
@@ -0,0 +1,15 @@
+<?php
+namespace Digitigrade;
+
+class PageLink implements \JsonSerializable {
+ /** @var CharacterRange[] */
+ public array $range;
+ public string $target;
+
+ public function jsonSerialize(): array {
+ return [
+ 'range' => $this->range,
+ 'target' => $this->target
+ ];
+ }
+} \ No newline at end of file
diff --git a/locale/en_GB.json b/locale/en_GB.json
index 69bf6a1..c0bd6a0 100644
--- a/locale/en_GB.json
+++ b/locale/en_GB.json
@@ -42,6 +42,8 @@
"note.action.delete": "Delete note",
"note.action.delete.confirmation": "Are you sure you want to permanently delete this note?",
"note.openRemote": "Open on original instance",
+ "note.openPage": "View page",
+ "note.page.summaryMessage": "Published a new page: \"%s\"",
"note.info.replyTo": "In reply to %s",
"note.info.mentions": "Mentions %s",
"note.privacy.scope.none": "Private",
@@ -49,6 +51,14 @@
"note.privacy.scope.followers": "Followers only",
"note.privacy.scope.public": "Public",
"note.resharedBy": "Reshared by %s",
+ "note.page.author": "By %s",
+ "note.page.created": "First published %s",
+ "note.page.modified": "Last updated %s",
+ "note.page.list.pageTitle": "Pages",
+ "note.page.new.pageTitle": "Write a new page",
+ "note.page.new.title.label": "Title",
+ "note.page.new.body.label": "Body",
+ "note.page.new.action": "Publish",
"placeholder": "There's nothing here.",
"placeholder.moreItems": "…and %d more",
"placeholder.noMoreItems": "No more items.",
diff --git a/misc/format_datetime.php b/misc/format_datetime.php
index 1a4db52..96ca3a2 100644
--- a/misc/format_datetime.php
+++ b/misc/format_datetime.php
@@ -1,5 +1,8 @@
<?php
+use Digitigrade\Model\UserAccount;
+use Digitigrade\UserSettings;
+
function format_datetime(
DateTimeInterface $dt,
DateTimeZone|IntlTimeZone|string|null $tz = null
@@ -9,6 +12,9 @@ function format_datetime(
IntlDateFormatter::RELATIVE_MEDIUM,
IntlDateFormatter::SHORT
);
- $formatter->setTimeZone($tz ?? $dt->getTimezone());
+ static $user = UserAccount::findByCurrentSession();
+ static $prefs = $user == null ? null : new UserSettings($user);
+
+ $formatter->setTimeZone($tz ?? $prefs?->get('locale.timezone') ?? $dt->getTimezone());
return $formatter->format($dt);
} \ No newline at end of file
diff --git a/routes/lookup.php b/routes/lookup.php
index e0a790e..62d9e48 100644
--- a/routes/lookup.php
+++ b/routes/lookup.php
@@ -34,9 +34,7 @@ Router::getInstance()->mount('/lookup', function (array $args) {
throw new NotFound("i can't find any object with that uri, sorry!");
}
- if ($object instanceof Note) {
- throw new TemporaryRedirect('/@/' . $object->author->getFullHandle() . "/note/$object->id");
- } elseif ($object instanceof Actor) {
- throw new TemporaryRedirect('/@/' . $object->getFullHandle());
+ if ($object instanceof Note || $object instanceof Actor) {
+ throw new TemporaryRedirect($object->getLocalUiHref());
}
}); \ No newline at end of file
diff --git a/routes/note.php b/routes/note.php
index 2612f0f..6dd0025 100644
--- a/routes/note.php
+++ b/routes/note.php
@@ -2,7 +2,6 @@
use Digitigrade\HttpResponseStatus\Forbidden;
use Digitigrade\HttpResponseStatus\NotFound;
-use Digitigrade\HttpResponseStatus\PolicyRejected;
use Digitigrade\HttpResponseStatus\TemporaryRedirect;
use Digitigrade\Model\Instance;
use Digitigrade\Model\Note;
@@ -22,7 +21,7 @@ Router::getInstance()->mount('/note/:id', function (array $args) {
}
PolicyManager::getInstance()->checkFederationOrThrow($note, $instance);
if (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'text/html')) {
- throw new TemporaryRedirect('/@/' . $note->author->handle . "/note/$note->id");
+ throw new TemporaryRedirect($note->getLocalUiHref());
}
// if it's not public we need to check whether the requesting instance is allowed to see it
if ($note->privacy->scope != NotePrivacyScope::PUBLIC ) {
@@ -49,7 +48,7 @@ Router::getInstance()->mount('/@/:handle/note/:id', function (array $args) {
}
// change the handle in the url if it's wrong
if ($args['handle'] != $note->author->getFullHandle()) {
- throw new TemporaryRedirect('/@/' . $note->author->getFullHandle() . "/note/$note->id");
+ throw new TemporaryRedirect($note->getLocalUiHref());
}
render_template('thread_page', ['note' => $note]);
});
diff --git a/routes/pages.php b/routes/pages.php
new file mode 100644
index 0000000..03ec4d4
--- /dev/null
+++ b/routes/pages.php
@@ -0,0 +1,76 @@
+<?php
+
+use Digitigrade\FormattingProvider\CommonMark;
+use Digitigrade\HttpResponseStatus\Forbidden;
+use Digitigrade\HttpResponseStatus\NotFound;
+use Digitigrade\HttpResponseStatus\PolicyRejected;
+use Digitigrade\HttpResponseStatus\TemporaryRedirect;
+use Digitigrade\Model\Note;
+use Digitigrade\Model\NotePrivacyScope;
+use Digitigrade\Model\UserAccount;
+use Digitigrade\Page;
+use Digitigrade\PolicyManager;
+use Digitigrade\Router;
+
+Router::getInstance()->mount('/pages', function (array $args) {
+ // list all pages
+ // it would be silly to show this to logged out users honestly
+ UserAccount::requireByCurrentSession();
+ render_template('pages_list_page');
+});
+
+Router::getInstance()->mount('/pages/new', function (array $args) {
+ // creating a new page
+ $user = UserAccount::requireByCurrentSession();
+ if (isset($_POST['title'])) {
+ $title = trim($_POST['title']);
+ $body = trim($_POST['body']);
+ $note = Note::create(
+ $user->actor,
+ $title,
+ get_ui_language(), // TODO: actual language selector/detection
+ null,
+ NotePrivacyScope::from($_POST['scope'])
+ );
+ $note->plainContent .= ' - ' . path_to_uri($note->getLocalUiHref(true));
+ $note->setPage(Page::create($title, [
+ 'text/markdown' => $body,
+ 'text/html' => CommonMark::getInstance()->renderToHtml($body)
+ ]));
+ if (!PolicyManager::getInstance()->check($note)) {
+ $note->remove();
+ throw new PolicyRejected();
+ }
+ $note->save();
+ $note->publish();
+ $note->processTimelineAdditions();
+ $note->processNotifications();
+
+ throw new TemporaryRedirect($note->getLocalUiHref(true));
+ } else {
+ render_template('pages_new_page');
+ }
+});
+
+Router::getInstance()->mount('/@/:handle/page/:id', function (array $args) {
+ $note = Note::find($args['id']);
+ if ($note == null || $note->deleted) {
+ throw new NotFound("i don't know that note");
+ }
+ // check the current user is allowed to see it
+ if ($note->privacy->scope != NotePrivacyScope::PUBLIC ) {
+ $user = UserAccount::requireByCurrentSession();
+ if (!in_array($user->actor, $note->getRelevantActors())) {
+ throw new Forbidden();
+ }
+ }
+ // go back to the note if it's not a page
+ if (!$note->hasPage()) {
+ throw new TemporaryRedirect($note->getLocalUiHref());
+ }
+ // change the handle in the url if it's wrong
+ if ($args['handle'] != $note->author->getFullHandle()) {
+ throw new TemporaryRedirect($note->getLocalUiHref(true));
+ }
+ render_template('note_page', ['note' => $note]);
+}); \ No newline at end of file
diff --git a/static/form.css b/static/form.css
index 7b38860..6f474c1 100644
--- a/static/form.css
+++ b/static/form.css
@@ -110,6 +110,15 @@ form:not(.nopanel) {
}
}
+ &#writePageForm {
+ max-width: 100%;
+ input,
+ textarea,
+ select {
+ width: 100%;
+ }
+ }
+
&#writeNoteForm {
max-width: 100%;
diff --git a/static/icons.css b/static/icons.css
index 3d53416..af17feb 100644
--- a/static/icons.css
+++ b/static/icons.css
@@ -117,4 +117,7 @@
&.orbit-outline {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M6 21.5q-1.45 0-2.475-1.025T2.5 18t1.025-2.475T6 14.5t2.475 1.025T9.5 18q0 .55-.162 1.063t-.488.962v-.675q.75.325 1.55.488T12 20q3.35 0 5.675-2.325T20 12h2q0 2.075-.788 3.9t-2.137 3.175t-3.175 2.138T12 22q-1.125 0-2.2-.238t-2.075-.712q-.4.225-.837.338T6 21.5m0-2q.625 0 1.063-.437T7.5 18t-.437-1.062T6 16.5t-1.062.438T4.5 18t.438 1.063T6 19.5m6-4q-1.45 0-2.475-1.025T8.5 12t1.025-2.475T12 8.5t2.475 1.025T15.5 12t-1.025 2.475T12 15.5M2 12q0-2.075.788-3.9t2.137-3.175T8.1 2.788T12 2q1.125 0 2.2.238t2.075.712q.4-.225.838-.338T18 2.5q1.45 0 2.475 1.025T21.5 6t-1.025 2.475T18 9.5t-2.475-1.025T14.5 6q0-.55.163-1.062t.487-.963v.675q-.75-.325-1.55-.488T12 4Q8.65 4 6.325 6.325T4 12zm16-4.5q.625 0 1.063-.437T19.5 6t-.437-1.062T18 4.5t-1.062.438T16.5 6t.438 1.063T18 7.5M18 6'/%3E%3C/svg%3E");
}
+ &.article-outline {
+ --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M7 17h7v-2H7zm0-4h10v-2H7zm0-4h10V7H7zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zM5 5v14z'/%3E%3C/svg%3E");
+ }
}
diff --git a/static/misc.css b/static/misc.css
index ab2ba35..1f39795 100644
--- a/static/misc.css
+++ b/static/misc.css
@@ -1,3 +1,7 @@
+h1 {
+ font-size: var(--font-heading-size);
+}
+
.alertbox {
border: var(--border);
border-radius: var(--border-radius);
@@ -14,7 +18,9 @@
}
button.primary,
-button.secondary {
+button.secondary,
+a.button.primary,
+a.button.secondary {
border-radius: var(--border-radius);
border: none;
padding: var(--spacing-single) var(--spacing-double);
@@ -26,6 +32,8 @@ button.secondary {
display: flex;
align-items: center;
justify-content: center;
+ gap: var(--spacing-single);
+ text-decoration: none;
&:disabled {
opacity: 80%;
@@ -39,6 +47,12 @@ button.secondary {
background: var(--clr-secondary);
color: var(--clr-foreground-on-secondary);
}
+ &.minWidth {
+ width: max-content;
+ }
+ &.margined {
+ margin: var(--spacing-double);
+ }
}
button.material-symbols {
diff --git a/static/note.css b/static/note.css
index b1453dc..bdb1422 100644
--- a/static/note.css
+++ b/static/note.css
@@ -73,6 +73,7 @@ article.note {
hyphens: auto;
overflow-x: hidden;
text-overflow: ellipsis;
+ line-break: anywhere;
.selected & {
font-size: var(--font-big-size);
}
@@ -280,6 +281,10 @@ article.note {
}
}
}
+
+ a.button {
+ margin: var(--spacing-half) 0 var(--spacing-double) 0;
+ }
}
.linkPreview {
diff --git a/static/pages.css b/static/pages.css
new file mode 100644
index 0000000..f19a856
--- /dev/null
+++ b/static/pages.css
@@ -0,0 +1,97 @@
+.notePage {
+ margin: var(--spacing-double);
+ article h1 {
+ margin: 0;
+ padding: var(--spacing-double) 0 var(--spacing-half) 0;
+ font-size: var(--font-heading-size);
+ }
+ .created,
+ .modified {
+ font-size: var(--font-small-size);
+ }
+
+ hr {
+ margin: var(--spacing-double) 0;
+ }
+}
+.pageAuthor {
+ display: flex;
+ gap: var(--spacing-single);
+ color: inherit;
+ text-decoration: none;
+ margin-bottom: var(--spacing-single);
+ align-items: center;
+
+ .displayName {
+ font-weight: bold;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+ img {
+ width: var(--icon-size);
+ aspect-ratio: 1;
+ }
+}
+
+.pageContent {
+ margin-top: var(--spacing-double);
+
+ p,
+ ul,
+ ol,
+ blockquote {
+ margin: 0 0 0.4lh 0;
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ blockquote {
+ padding-left: var(--spacing-double);
+ border-left: var(--border);
+ border-left-color: var(--clr-lesser-foreground);
+ }
+
+ h1,
+ h2 {
+ margin: 0.2lh 0 0.2lh 0;
+ font-size: var(--font-big-size);
+ }
+ h3,
+ h4,
+ h5,
+ h6 {
+ margin: 0.1lh 0 0.2lh 0;
+ font-size: var(--font-normal-size);
+ }
+
+ img {
+ max-height: 8lh;
+ }
+}
+
+.pageItem {
+ display: grid;
+ grid-template-columns: max-content auto;
+ border: var(--border);
+ border-radius: var(--border-radius);
+ background-color: var(--clr-note-background);
+ margin: var(--spacing-double);
+ padding: var(--spacing-double);
+ gap: var(--spacing-double);
+ text-decoration: none;
+ color: inherit;
+
+ img {
+ width: var(--avatar-size);
+ aspect-ratio: 1;
+ border-radius: var(--border-radius);
+ }
+
+ .title {
+ font-size: var(--font-big-size);
+ font-weight: bold;
+ }
+}
diff --git a/static/style.css b/static/style.css
index 34de2d2..c1b3bab 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3,6 +3,7 @@
@import url(skeleton.css);
@import url(notifications.css);
@import url(note.css);
+@import url(pages.css);
@import url(form.css);
@import url(pills.css);
@import url(profile.css);
diff --git a/static/theme.css b/static/theme.css
index 7e8ce2a..a542cae 100644
--- a/static/theme.css
+++ b/static/theme.css
@@ -39,6 +39,7 @@
--font-normal-size: 12pt;
--font-small-size: 10pt;
--font-big-size: 16pt;
+ --font-heading-size: 20pt;
/* misc */
--border: 1px solid var(--clr-border);
diff --git a/templates/formatting_hint.php b/templates/formatting_hint.php
new file mode 100644
index 0000000..e7163ac
--- /dev/null
+++ b/templates/formatting_hint.php
@@ -0,0 +1,5 @@
+<span class="hint">
+ <span class="inlineIcon material-symbols markdown-outline"></span>
+ <span><?= sprintf(__('writeNote.formattingHint'),
+ '<a href="https://commonmark.org/help/" target="_blank">' . __('writeNote.formattingHint.markdown') . '</a>') ?></span>
+</span> \ No newline at end of file
diff --git a/templates/left_side_navigation.php b/templates/left_side_navigation.php
index 4cf8b55..5d7f368 100644
--- a/templates/left_side_navigation.php
+++ b/templates/left_side_navigation.php
@@ -12,6 +12,10 @@ if ($user != null) {
'label' => __('navigation.followRequests'),
'unreadCount' => FollowRelation::countWhere("object = ? AND status = 'pending'", [$user->actor->id])
], [
+ 'href' => '/pages',
+ 'icon' => 'article-outline',
+ 'label' => __('note.page.list.pageTitle')
+ ], [
'href' => '/preferences',
'icon' => 'tune',
'label' => __('preferences.pageTitle')
diff --git a/templates/note.php b/templates/note.php
index 759a7c7..c80eace 100644
--- a/templates/note.php
+++ b/templates/note.php
@@ -17,7 +17,7 @@ if ($user != null) {
$interactions = $note->getInteractionsWithAuthor($user->actor);
$interKinds = array_map(fn($inter) => $inter->kind, $interactions);
}
-$pathToSelf = '/@/' . $note->author->getFullHandle() . '/note/' . $note->id;
+$pathToSelf = $note->getLocalUiHref();
?>
<article class="note <?= ($selected ?? false) ? 'selected' : '' ?>">
@@ -36,9 +36,9 @@ $pathToSelf = '/@/' . $note->author->getFullHandle() . '/note/' . $note->id;
} ?>
</span>
<a class="timestamp" href="<?= htmlspecialchars($pathToSelf) ?>">
- <?= format_datetime($note->created, $prefs?->get('locale.timezone'))
+ <?= format_datetime($note->created)
. (isset($note->modified) && $note->modified != $note->created
- ? ' (' . format_datetime($note->modified, $prefs?->get('locale.timezone')) . ')'
+ ? ' (' . format_datetime($note->modified) . ')'
: ''
) ?>
</a>
@@ -81,7 +81,11 @@ $pathToSelf = '/@/' . $note->author->getFullHandle() . '/note/' . $note->id;
<summary><?= htmlspecialchars($note->summary) ?></summary>
<?php endif; ?>
<div class="content" lang="<?= $note->language ?>">
- <?= $note->getBestContentAsHtml() ?>
+ <?php if ($note->hasPage()): ?>
+ <?= __f('note.page.summaryMessage', htmlspecialchars($note->getPage()->title)) ?>
+ <?php else: ?>
+ <?= $note->getBestContentAsHtml() ?>
+ <?php endif; ?>
</div>
<?php if (count($note->attachments) > 0): ?>
@@ -105,6 +109,13 @@ $pathToSelf = '/@/' . $note->author->getFullHandle() . '/note/' . $note->id;
</details>
<?php endif; ?>
+ <?php if ($note->hasPage()): ?>
+ <a class="button secondary minWidth" href="<?= $note->getLocalUiHref(true) ?>">
+ <span class="material-symbols article-outline"></span>
+ <span><?= __('note.openPage') ?></span>
+ </a>
+ <?php endif; ?>
+
<div class="buttons">
<?php foreach (['like', 'dislike', 'reshare'] as $action) {
$kind = InteractionKind::from($action);
diff --git a/templates/note_page.php b/templates/note_page.php
new file mode 100644
index 0000000..647c64e
--- /dev/null
+++ b/templates/note_page.php
@@ -0,0 +1,36 @@
+<?php
+
+/** @var \Digitigrade\Model\Note $note */
+
+use Digitigrade\FormattingProvider;
+use Digitigrade\Model\UserAccount;
+use Digitigrade\UserSettings;
+
+$page = $note->getPage();
+
+call_template('skeleton', ['pageTitle' => $page->title, 'renderTitleHeading' => false], function () use ($note, $page) { ?>
+ <div class="notePage">
+ <article lang="<?= htmlspecialchars($note->language) ?>">
+ <h1><?= htmlspecialchars($page->title) ?></h1>
+ <a class="pageAuthor" href="/@/<?= htmlspecialchars($note->author->getFullHandle()) ?>">
+ <?php call_template('actor_avatar', ['actor' => $note->author]); ?>
+ <span><?= __f(
+ 'note.page.author',
+ '<span class="displayName">' . htmlspecialchars($note->author->displayName) . '</span>'
+ ) ?></span>
+ </a>
+ <div class="created">
+ <?= __f('note.page.created', format_datetime($note->created)) ?>
+ </div>
+ <?php if (isset($note->modified) && $note->modified != $note->created): ?>
+ <div class="modified">
+ <?= __f('note.page.modified', format_datetime($note->modified)) ?>
+ </div>
+ <?php endif; ?>
+ <hr>
+ <div class="pageContent">
+ <?= $page->getBestContentAsHtml() ?>
+ </div>
+ </article>
+ </div>
+<?php }); \ No newline at end of file
diff --git a/templates/pages_list_item.php b/templates/pages_list_item.php
new file mode 100644
index 0000000..7944956
--- /dev/null
+++ b/templates/pages_list_item.php
@@ -0,0 +1,11 @@
+<?php
+/** @var \Digitigrade\Model\Note $note */
+$page = $note->getPage();
+?>
+<a class="pageItem" href="<?= htmlspecialchars($note->getLocalUiHref(true)) ?>">
+ <?= call_template('actor_avatar', ['actor' => $note->author]) ?>
+ <div>
+ <div class="title"><?= htmlspecialchars($page->title) ?></div>
+ <div class="author"><?= htmlspecialchars($note->author->displayName) ?></div>
+ </div>
+</a> \ No newline at end of file
diff --git a/templates/pages_list_page.php b/templates/pages_list_page.php
new file mode 100644
index 0000000..2dd1af2
--- /dev/null
+++ b/templates/pages_list_page.php
@@ -0,0 +1,19 @@
+<?php
+
+use Digitigrade\Model\Note;
+
+call_template('skeleton', ['pageTitle' => __('note.page.list.pageTitle')], function () {
+ ?>
+ <a class="button secondary margined" href="/pages/new"><?= __('note.page.new.pageTitle') ?></a>
+ <?php
+
+ $notes = Note::findAllWithExtension(Note::EXTENSION_PAGES, 20);
+ foreach ($notes as $note) {
+ call_template('pages_list_item', ['note' => $note]);
+ }
+ if (count($notes) == 0) {
+ call_template('placeholder_text');
+ } else {
+ call_template('placeholder_text', ['count' => Note::countWithExtension(Note::EXTENSION_PAGES) - count($notes)]);
+ }
+}); \ No newline at end of file
diff --git a/templates/pages_new_form.php b/templates/pages_new_form.php
new file mode 100644
index 0000000..a8dde16
--- /dev/null
+++ b/templates/pages_new_form.php
@@ -0,0 +1,26 @@
+<form id="writePageForm" hx-post="" hx-disabled-elt="find button">
+ <div class="row">
+ <label for="newPageTitle"><?= __('note.page.new.title.label') ?></label>
+ <input type="text" id="newPageTitle" name="title" required>
+ </div>
+
+ <div class="row">
+ <label for="newPageBody"><?= __('note.page.new.body.label') ?></label>
+ <textarea id="newPageBody" name="body" required></textarea>
+ <?php call_template('formatting_hint'); ?>
+ </div>
+
+ <div class="row">
+ <label for="newPageScope"><?= __('writeNote.scope.label') ?></label>
+ <select name="scope" id="newPageScope" required autocomplete="off">
+ <option value="none"><?= __('note.privacy.scope.none') ?></option>
+ <option value="mutuals"><?= __('note.privacy.scope.mutuals') ?></option>
+ <option value="followers"><?= __('note.privacy.scope.followers') ?></option>
+ <option value="public" selected><?= __('note.privacy.scope.public') ?></option>
+ </select>
+ </div>
+
+ <div class="row">
+ <button type="submit" class="primary"><?= __('note.page.new.action') ?></button>
+ </div>
+</form> \ No newline at end of file
diff --git a/templates/pages_new_page.php b/templates/pages_new_page.php
new file mode 100644
index 0000000..e047b99
--- /dev/null
+++ b/templates/pages_new_page.php
@@ -0,0 +1,4 @@
+<?php
+call_template('skeleton', ['pageTitle' => __('note.page.new.pageTitle')], function () {
+ call_template('pages_new_form');
+}); \ No newline at end of file
diff --git a/templates/reply_form.php b/templates/reply_form.php
index 59d2d53..2d8cfc3 100644
--- a/templates/reply_form.php
+++ b/templates/reply_form.php
@@ -30,11 +30,7 @@ $selectedScope = $note->privacy->scope->value;
value="<?= $summaryText ?>" placeholder="<?= __('writeNote.summary.label') ?>">
<textarea id="replyContent-<?= $note->id ?>" name="content" required autocomplete="off"
placeholder="<?= __('writeReply.placeholder') ?>"></textarea>
- <span class="hint">
- <span class="inlineIcon material-symbols markdown-outline"></span>
- <span><?= sprintf(__('writeNote.formattingHint'),
- '<a href="https://commonmark.org/help/" target="_blank">' . __('writeNote.formattingHint.markdown') . '</a>') ?></span>
- </span>
+ <?php call_template('formatting_hint'); ?>
</div>
<details>
<summary><?= __('writeNote.moreOptions') ?></summary>
diff --git a/templates/write_note_form.php b/templates/write_note_form.php
index 1062a27..394f7ba 100644
--- a/templates/write_note_form.php
+++ b/templates/write_note_form.php
@@ -21,11 +21,7 @@
placeholder="<?= __('writeNote.summary.label') ?>">
<textarea name="content" id="noteContent" required autocomplete="off"
placeholder="<?= __('writeNote.placeholder') ?>"></textarea>
- <span class="hint">
- <span class="inlineIcon material-symbols markdown-outline"></span>
- <span><?= sprintf(__('writeNote.formattingHint'),
- '<a href="https://commonmark.org/help/" target="_blank">' . __('writeNote.formattingHint.markdown') . '</a>') ?></span>
- </span>
+ <?php call_template('formatting_hint'); ?>
</div>
<details>
<summary><?= __('writeNote.moreOptions') ?></summary>