summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonas Kohl2024-10-19 12:38:45 +0200
committerJonas Kohl2024-10-19 12:38:45 +0200
commitf490626b8a2ff360c4a914615484ea6e5bf8cdee (patch)
tree96cbbcea2a63283b44186a32fcf6528f692f6523
parent3c8f4f695f1b9ec7a188b85e0ce38bc69c697008 (diff)
Add modern theme
-rw-r--r--src/application/templates/modern/base.twig222
-rw-r--r--src/application/templates/modern/components/alert_error.twig8
-rw-r--r--src/application/templates/modern/components/alert_info.twig8
-rw-r--r--src/application/templates/modern/components/alert_success.twig8
-rw-r--r--src/application/templates/modern/components/post.twig180
-rw-r--r--src/application/templates/modern/components/richtext_editor.twig35
-rw-r--r--src/application/templates/modern/components/topic_log.twig68
-rw-r--r--src/application/templates/modern/delete_post.twig40
-rw-r--r--src/application/templates/modern/delete_topic.twig37
-rw-r--r--src/application/templates/modern/error_page.twig12
-rw-r--r--src/application/templates/modern/info_page.twig12
-rw-r--r--src/application/templates/modern/login.twig46
-rw-r--r--src/application/templates/modern/new_password.twig38
-rw-r--r--src/application/templates/modern/new_topic.twig39
-rw-r--r--src/application/templates/modern/password_reset.twig38
-rw-r--r--src/application/templates/modern/register.twig92
-rw-r--r--src/application/templates/modern/search.twig69
-rw-r--r--src/application/templates/modern/view_topic.twig367
-rw-r--r--src/application/templates/modern/view_topics.twig28
-rw-r--r--src/application/templates/modern/view_user.twig196
-rw-r--r--src/application/themes/modern/theme.json11
-rw-r--r--src/ui/theme-files/modern/InterVariable-Italic.woff2bin0 -> 380904 bytes
-rw-r--r--src/ui/theme-files/modern/InterVariable.woff2bin0 -> 345588 bytes
-rw-r--r--src/ui/theme-files/modern/theme.css970
24 files changed, 2524 insertions, 0 deletions
diff --git a/src/application/templates/modern/base.twig b/src/application/templates/modern/base.twig
new file mode 100644
index 0000000..819af80
--- /dev/null
+++ b/src/application/templates/modern/base.twig
@@ -0,0 +1,222 @@
+{%- if title -%}
+ {%- set title = title ~ " | " -%}
+{%- endif -%}
+{%- set title = title ~ (g.env.MYSTIC_FORUM_TITLE|default("Forum")) -%}
+{%- set nextParam = "" -%}
+{%- if g.globals.action in ["login", "register"] -%}
+ {%- set nextParam = g.get.next|default("") -%}
+{%- else -%}
+ {%- set nextParam = g.server.REQUEST_URI -%}
+{%- endif -%}
+<!DOCTYPE html>
+<html lang="{{ __("en", context: "HTML language") }}">
+<head>
+ <meta charset="utf-8">
+ <meta name="color-scheme" content="light dark">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <meta name="generator" content="mysticBB {{ constant("MYSTICBB_VERSION") }}">
+ <title>{{ title }}</title>
+ <link rel="stylesheet" href=".?_action=ctheme">
+ <script src=".?_action=ji18n"></script>
+ {% block head_after %}{% endblock %}
+</head>
+<body>
+
+{% block nav %}
+<header>
+ <a id="brand" href=".">{{ g.env.MYSTIC_FORUM_TITLE|default("Forum") }}</a>
+ <nav>
+ {% block navbar %}
+ {% if currentUser %}
+ <a aria-label="{{ __("Search") }}" href="?_action=search"{{ g.globals.action == "search" ? ' class="active"'|raw : '' }}>
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
+ </a>
+ <a aria-label="{{ __("View profile") }}" href="?_action=viewuser&amp;user={{ currentUser.id|url_encode }}"{{ (g.globals.action == "viewuser" and g.get.user == currentUser.id) ? ' class="active"'|raw : '' }}>
+ <svg viewBox="0 0 24 24" class="icon"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
+ </a>
+ <a aria-label="{{ __("Log out") }}" href="?_action=logout&amp;next={{ g.server.REQUEST_URI|url_encode }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
+ </a>
+ {% else %}
+ <a aria-label="{{ __("Search") }}" href="?_action=search"{{ g.globals.action == "search" ? ' class="active"'|raw : '' }}>
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
+ </a>
+ <a href="?_action=auth&amp;next={{ nextParam|url_encode }}"{{ g.globals.action == "auth" ? ' class="active"'|raw : '' }}>{{ __("Log in") }}</a>
+ {% if constant("REGISTRATION_ENABLED") %}
+ <a href="?_action=register&amp;next={{ nextParam|url_encode }}"{{ g.globals.action == "register" ? ' class="active"'|raw : '' }}>{{ __("Register") }}</a>
+ {% endif %}
+ {% endif %}
+ {% endblock %}
+ </nav>
+</header>
+{% endblock %}
+
+{% block main %}
+<main>
+ {% block content %}{% endblock %}
+</main>
+{% endblock %}
+
+{% block footer %}
+<footer>
+ <div id="about">
+ &copy; {{ "now"|date("Y") }} {{ g.env.MYSTIC_FORUM_COPYRIGHT|default(g.env.MYSTIC_FORUM_TITLE)|default("Forum") }}.
+ Powered by <a href="https://git.jkohl.link/mystic-forum.git/tag/?h=v{{ constant("MYSTICBB_VERSION")|url_encode }}">mysticBB v{{ constant("MYSTICBB_VERSION") }}</a>.
+ </div>
+ <div id="preferences">
+ <form action="?_action=settheme" class="form-inline" method="post">
+ <input type="hidden" name="next" value="{{ g.server.REQUEST_URI }}">
+ <div class="form-group">
+ <label for="theme-select">{{ __("Theme:") }}</label>
+ <select id="theme-select" name="theme" onchange="this.form.submit()">
+ {% for themeKey, themeInfo in availableThemes %}
+ <option value="{{ themeKey }}"{{ themeKey == currentTheme ? " selected" : "" }}>{{ themeInfo.name }}</option>
+ {% endfor %}
+ </select>
+ </div>
+ </form>
+ <form action="?_action=setlang" class="form-inline" method="post">
+ <input type="hidden" name="next" value="{{ g.server.REQUEST_URI }}">
+ <div class="form-group">
+ <label for="lang-select">{{ __("Language:") }}</label>
+ <select id="lang-select" name="lang" onchange="this.form.submit()">
+ {% for langKey, langName in availableLangs %}
+ <option value="{{ langKey }}"{{ langKey == currentLang ? " selected" : "" }}>{{ langName }}</option>
+ {% endfor %}
+ </select>
+ </div>
+ </form>
+ </div>
+</footer>
+{% endblock %}
+
+{% block scripts %}
+<script type="text/javascript">
+ (function() {
+ function insertAroundSelection(textarea, before, after) {
+ var start = textarea.selectionStart;
+ var end = textarea.selectionEnd;
+ var text = textarea.value;
+ var pre = text.substring(0, start);
+ var inner = text.substring(start, end);
+ var post = text.substring(end);
+ start += before.length;
+ end += before.length;
+ text = pre + before + inner + after + post;
+ textarea.value = text;
+ textarea.focus();
+ textarea.selectionStart = start;
+ textarea.selectionEnd = end;
+ }
+
+ function getTextarea(btn) {
+ return document.querySelector(btn.dataset.area);
+ }
+
+ var commands = {
+ bold: function(textarea) {
+ insertAroundSelection(textarea, "[b]", "[/b]");
+ },
+ italic: function(textarea) {
+ insertAroundSelection(textarea, "[i]", "[/i]");
+ },
+ underline: function(textarea) {
+ insertAroundSelection(textarea, "[u]", "[/u]");
+ },
+ strikethrough: function(textarea) {
+ insertAroundSelection(textarea, "[s]", "[/s]");
+ },
+ sup: function(textarea) {
+ insertAroundSelection(textarea, "[^]", "[/^]");
+ },
+ sub: function(textarea) {
+ insertAroundSelection(textarea, "[_]", "[/_]");
+ },
+ quote: function(textarea) {
+ insertAroundSelection(textarea, "> ", "");
+ },
+ spoiler: function(textarea) {
+ insertAroundSelection(textarea, "[spoiler]", "[/spoiler]");
+ }
+ }
+
+ document.querySelectorAll("button[data-editor-command]").forEach(e => {
+ e.addEventListener("click", function() {
+ const command = e.dataset.editorCommand;
+ const textarea = getTextarea(e);
+ commands[command](textarea);
+ });
+ });
+ })();
+</script>
+
+<script>
+ document.querySelectorAll("._time").forEach(e => {
+ const date = new Date(e.textContent.trim());
+ e.textContent = date.toLocaleString();
+ });
+ document.querySelectorAll("._date").forEach(e => {
+ const date = new Date(e.textContent.trim());
+ e.textContent = date.toLocaleDateString();
+ });
+ document.querySelectorAll("._time-only").forEach(e => {
+ const date = new Date(e.textContent.trim());
+ e.textContent = date.toLocaleTimeString();
+ });
+ document.querySelectorAll("[data-dismiss='modal']").forEach(e => {
+ e.addEventListener("click", () => {
+ const modal = e.closest(".modal");
+ if (!modal)
+ return;
+ modal.classList.add("fade");
+ modal.dispatchEvent(new Event("hidemodal"));
+ });
+ })
+</script>
+
+<script>
+ // feature detection
+ (function() {
+ var unsupportedFeatures = [];
+
+ function notSupported(feat) {
+ unsupportedFeatures.push("- " + feat);
+ }
+
+ var supports = {
+ cssNesting: ((function() {
+ try {
+ document.querySelector("&");
+ return true;
+ } catch (e) {
+ return false;
+ }
+ })()),
+ es6Syntax: ((function() {
+ try {
+ Function("() => {};");
+ return true;
+ } catch (e) {
+ return false;
+ }
+ })())
+ };
+
+ if (!supports.cssNesting) notSupported("CSS nesting");
+ if (!supports.es6Syntax) notSupported("ECMAScript 6 Syntax");
+
+ if (unsupportedFeatures.length > 0) {
+ alert(
+ "ATTENTION: This site might not work properly because your " +
+ "browser does not support some required features: \n\n" +
+ unsupportedFeatures.join("\n") +
+ "\n\n" +
+ "Please try chaning the theme to something else!"
+ );
+ }
+ })();
+</script>
+{% endblock %}
+
+</body>
+</html>
diff --git a/src/application/templates/modern/components/alert_error.twig b/src/application/templates/modern/components/alert_error.twig
new file mode 100644
index 0000000..ba14ba5
--- /dev/null
+++ b/src/application/templates/modern/components/alert_error.twig
@@ -0,0 +1,8 @@
+<div class="alert alert-danger" role="alert">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
+ {% if message starts with "?!HTML::" %}
+ {{ message|slice(8)|raw }}
+ {% else %}
+ <span>{{ message }}</span>
+ {% endif %}
+</div>
diff --git a/src/application/templates/modern/components/alert_info.twig b/src/application/templates/modern/components/alert_info.twig
new file mode 100644
index 0000000..6559ac2
--- /dev/null
+++ b/src/application/templates/modern/components/alert_info.twig
@@ -0,0 +1,8 @@
+<div class="alert alert-info" role="alert">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
+ {% if message starts with "?!HTML::" %}
+ {{ message|slice(8)|raw }}
+ {% else %}
+ <span>{{ message }}</span>
+ {% endif %}
+</div>
diff --git a/src/application/templates/modern/components/alert_success.twig b/src/application/templates/modern/components/alert_success.twig
new file mode 100644
index 0000000..ed64ce5
--- /dev/null
+++ b/src/application/templates/modern/components/alert_success.twig
@@ -0,0 +1,8 @@
+<div class="alert alert-success" role="alert">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
+ {% if message starts with "?!HTML::" %}
+ {{ message|slice(8)|raw }}
+ {% else %}
+ <span>{{ message }}</span>
+ {% endif %}
+</div>
diff --git a/src/application/templates/modern/components/post.twig b/src/application/templates/modern/components/post.twig
new file mode 100644
index 0000000..5ceb46e
--- /dev/null
+++ b/src/application/templates/modern/components/post.twig
@@ -0,0 +1,180 @@
+{%- set fileAttachments = attachments|filter(a => not (a.mimeType starts with "image/" or a.mimeType starts with "video/")) -%}
+{%- set imageAttachments = attachments|filter(a => a.mimeType starts with "image/" or a.mimeType starts with "video/") -%}
+
+{%- set canReply =
+ not post.deleted
+ and not topic.isLocked
+ and currentUser is not null
+ and currentUser.hasPermission(permission("CREATE_OWN_POST"))
+-%}
+
+{%- set canEdit =
+ not post.deleted
+ and not topic.isLocked
+ and currentUser is not null
+ and (
+ (
+ postAuthor is not null
+ and postAuthor.id == currentUser.id
+ and postAuthor.hasPermission(permission("EDIT_OWN_POST"))
+ )
+ or currentUser.hasPermission(permission("EDIT_OTHER_POST"))
+ ) -%}
+
+{%- set canDelete =
+ not post.deleted
+ and currentUser is not null
+ and (
+ (
+ postAuthor is not null
+ and postAuthor.id == currentUser.id
+ and postAuthor.hasPermission(permission("DELETE_OWN_POST"))
+ )
+ or currentUser.hasPermission(permission("DELETE_OTHER_POST"))
+ ) -%}
+
+{%- set canViewAttachments = currentUser is not null -%}
+
+{%- set your_are_the_author =
+ currentUser is not null
+ and postAuthor is not null
+ and currentUser.id == postAuthor.id
+-%}
+
+{%- set is_op =
+ topicAuthor is not null
+ and postAuthor is not null
+ and postAuthor.id == topicAuthor.id
+-%}
+
+{% if post.deleted %}
+ <div class="post" id="post-{{ post.id }}">
+ <div class="post-pfp">
+ <div class="media-object" style="width:64px"></div>
+ </div>
+ <div class="post-body">
+ <div class="post-status post-status-danger">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
+ <em>{{ __("This post has been deleted") }}</em>
+ </div>
+ </div>
+ </div>
+{% else %}
+ <div class="post" data-text="{{ post.content }}" style="overflow: visible;" data-post-id="{{ post.id }}">
+ <div id="post-{{ post.id }}" class="post-anchor"></div>
+ {% if not hide_pfp %}
+ <div class="post-pfp">
+ {% if postAuthor %}
+ {% if hide_actions %}
+ <img class="media-object" alt="{{ __("Profile picture") }}" src="?_action=profilepicture&amp;user={{ postAuthor.id|url_encode }}" width="64" height="64">
+ {% else %}
+ <a href="?_action=viewuser&amp;user={{ postAuthor.id|url_encode }}">
+ <img class="media-object" alt="{{ __("Profile picture") }}" src="?_action=profilepicture&amp;user={{ postAuthor.id|url_encode }}" width="64" height="64">
+ </a>
+ {% endif %}
+ {% else %}
+ <div class="media-object" style="width:64px;height:64px"></div>
+ {% endif %}
+ </div>
+ {% endif %}
+ <div class="post-body" style="overflow: visible;">
+ <div class="post-title">
+ {% if not hide_actions %}
+ <div class="pull-right title-controls">
+ <a href="#post-{{ post.id }}" class="btn btn-iconic" title="{{ __("Permalink") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
+ </a>
+ {% if canReply %}
+ <button data-post-id="{{ post.id }}" class="btn js-only _reply-post btn-iconic" title="{{ __("Reply to post") }}">
+ <svg viewBox="0 0 24 24" class="icon"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
+ </button>
+ {% endif %}
+ {% if canEdit %}
+ <button data-post-id="{{ post.id }}" class="btn js-only _edit-post btn-iconic" title="{{ __("Edit post") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>
+ </button>
+ {% endif %}
+ {% if canDelete %}
+ <form action="?_action=deletepost" method="post">
+ <input type="hidden" name="post" value="{{ post.id }}">
+ <button type="submit" class="btn btn-danger btn-iconic" title="{{ __("Delete post") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
+ </button>
+ </form>
+ {% endif %}
+ </div>
+ {% endif %}
+ {% if postAuthor %}
+ {% if hide_actions %}
+ {{ postAuthor.displayName }}
+ {% else %}
+ <a href="?_action=viewuser&amp;user={{ postAuthor.id|url_encode }}">{{ postAuthor.displayName }}</a>
+ {% if is_op %}
+ <svg viewBox="0 0 24 24" class="icon is-op icon-in-text"><title>{{ __("Created this topic") }}</title><path d="m11 7.601-5.994 8.19a1 1 0 0 0 .1 1.298l.817.818a1 1 0 0 0 1.314.087L15.09 12"/><path d="M16.5 21.174C15.5 20.5 14.372 20 13 20c-2.058 0-3.928 2.356-6 2-2.072-.356-2.775-3.369-1.5-4.5"/><circle cx="16" cy="7" r="5"/></svg>
+ {% endif %}
+ {% endif %}
+ {% if your_are_the_author %}
+ <svg viewBox="0 0 24 24" class="icon is-you icon-in-text"><title>{{ __("You") }}</title><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
+ {% endif %}
+ {% else %}
+ <em class="text-muted">{{ __("(deleted)") }}</em>
+ {% endif %}
+ <div class="post-info">
+ <span class="_time">{{ post.postDate.format("c") }}</span>
+ {% if post.edited %}
+ <em class="text-muted">{{ __("(edited)") }}</em>
+ {% endif %}
+ </div>
+ </div>
+ <div class="post-main">
+ <div class="post-content">{{ renderPost(post.content) }}</div>
+ {% if imageAttachments|length > 0 %}
+ <div class="post-images">
+ {% for attachment in imageAttachments %}
+ {% if hide_actions %}
+ <span class="image-attachment" title="{{ attachment.name }}">
+ <img class="image-attachment-image" src="?_action=thumb&amp;attachment={{ attachment.id|url_encode }}" alt="" width="100">
+ </span>
+ {% else %}
+ <a class="
+ image-attachment
+ attachment
+ {{ attachment.mimeType starts with "video/" ? "video-attachment" }}
+ " href="?_action=attachment&amp;attachment={{ attachment.id|url_encode }}" title="{{ attachment.name }}" data-attachment-id="{{ attachment.id }}">
+ <img class="image-attachment-image" src="?_action=thumb&amp;attachment={{ attachment.id|url_encode }}" alt="" width="100">
+ {% if not canViewAttachments %}
+ <svg viewBox="0 0 24 24" class="attachment-lock icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
+ {% elseif attachment.mimeType starts with "video/" %}
+ <svg viewBox="0 0 24 24" class="video-player-icon icon"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
+ {% endif %}
+ </a>
+ {% endif %}
+ {% endfor %}
+ </div>
+ {% endif %}
+ </div>
+ {% if fileAttachments|length > 0 %}
+ <div class="post-attachments">
+ <div class="btn-toolbar">
+ <div class="btn-group">
+ {% for attachment in fileAttachments %}
+ {% if hide_actions %}
+ <button>{{ attachment.name }}</button>
+ {% else %}
+ <a class="btn attachment" href="?_action=attachment&amp;attachment={{ attachment.id|url_encode }}">
+ {% if not canViewAttachments %}
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>
+ {% else %}
+ <svg viewBox="0 0 24 24" class="icon"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
+ {% endif %}
+ <span>{{ attachment.name }}</span>
+ </a>
+ {% endif %}
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+{% endif %}
diff --git a/src/application/templates/modern/components/richtext_editor.twig b/src/application/templates/modern/components/richtext_editor.twig
new file mode 100644
index 0000000..3a2874d
--- /dev/null
+++ b/src/application/templates/modern/components/richtext_editor.twig
@@ -0,0 +1,35 @@
+<div class="richtext-editor">
+ <div class="rt-toolbar" role="toolbar">
+ <div class="rt-group" role="group">
+ <button data-area="#{{ id }}" title="{{ __("Bold") }}" data-editor-command="bold" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></svg>
+ </button>
+ <button data-area="#{{ id }}" title="{{ __("Italic") }}" data-editor-command="italic" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
+ </button>
+ <button data-area="#{{ id }}" title="{{ __("Underlined") }}" data-editor-command="underline" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
+ </button>
+ <button data-area="#{{ id }}" title="{{ __("Strikethrough") }}" data-editor-command="strikethrough" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
+ </button>
+ </div>
+ <div class="rt-group" role="group">
+ <button data-area="#{{ id }}" title="{{ __("Superscript") }}" data-editor-command="sup" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 0 0-2.617-.436c-.42.239-.738.614-.899 1.06"/></svg>
+ </button>
+ <button data-area="#{{ id }}" title="{{ __("Subscript") }}" data-editor-command="sub" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07"/></svg>
+ </button>
+ </div>
+ <div class="rt-group" role="group">
+ <button data-area="#{{ id }}" title="{{ __("Quote") }}" data-editor-command="quote" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>
+ </button>
+ <button data-area="#{{ id }}" title="{{ __("Spoiler") }}" data-editor-command="spoiler" type="button" class="btn btn-iconic">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
+ </button>
+ </div>
+ </div>
+ <textarea class="form-control" id="{{ id }}" name="{{ name }}" required rows="12" cols="60"></textarea>
+</div>
diff --git a/src/application/templates/modern/components/topic_log.twig b/src/application/templates/modern/components/topic_log.twig
new file mode 100644
index 0000000..853dc20
--- /dev/null
+++ b/src/application/templates/modern/components/topic_log.twig
@@ -0,0 +1,68 @@
+{%- set user = "" -%}
+{%- if postAuthor is null -%}
+ {%- set user = __("(deleted)")|e("html") -%}
+{%- else -%}
+ {%- set user =
+ '<a href="?_action=viewuser&amp;user='
+ ~ postAuthor.id|url_encode|e("html")
+ ~ '">'
+ ~ postAuthor.displayName|e("html")
+ ~ '</a>'
+ -%}
+{%- endif -%}
+
+<div class="post log" id="post-{{ logMessage.id }}">
+ <div class="post-pfp">
+ {% if postAuthor %}
+ {% if hideActions %}
+ <img class="media-object" alt="{{ __("Profile picture") }}" src="?_action=profilepicture&amp;user={{ postAuthor.id|url_encode }}" width="64" height="64">
+ {% else %}
+ <a href="?_action=viewuser&amp;user={{ postAuthor.id|url_encode }}">
+ <img class="media-object" alt="{{ __("Profile picture") }}" src="?_action=profilepicture&amp;user={{ postAuthor.id|url_encode }}" width="64" height="64">
+ </a>
+ {% endif %}
+ {% else %}
+ <div class="media-object" style="width:64px;height:64px"></div>
+ {% endif %}
+ </div>
+ <div class="post-body">
+ {% if logMessage.type == constant("mystic\\forum\\orm\\TopicLogMessage::LOCKED") %}
+ <div class="post-status post-status-warning">
+ <svg viewBox="0 0 24 24" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
+ <span>
+ <em>{{ __("%user% locked this topic", {
+ "user": user,
+ }) }}</em>
+ <br>
+ <small class="_time">{{ logMessage.postDate.format("c") }}</small>
+ </span>
+ </div>
+ {% elseif logMessage.type == constant("mystic\\forum\\orm\\TopicLogMessage::UNLOCKED") %}
+ <div class="post-status post-status-success">
+ <svg viewBox="0 0 24 24" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
+ <span>
+ <em>{{ __("%user% unlocked this topic", {
+ "user": user,
+ }) }}</em>
+ <br>
+ <small class="_time">{{ logMessage.postDate.format("c") }}</small>
+ </span>
+ </div>
+ {% elseif logMessage.type == constant("mystic\\forum\\orm\\TopicLogMessage::TITLE_CHANGED") %}
+ <div class="post-status post-status-info">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>
+ <span>
+ <em>{{ __("%user% changed the title of this topic from %old_title% to %new_title%", {
+ "user": user,
+ "old_title": '<strong>' ~ logMessage.params.old_value|default(__("unknown"))|e("html") ~ '</strong>',
+ "new_title": '<strong>' ~ logMessage.params.new_value|default(__("unknown"))|e("html") ~ '</strong>',
+ }) }}</em>
+ <br>
+ <small class="_time">{{ logMessage.postDate.format("c") }}</small>
+ </span>
+ </div>
+ {% else %}
+ {{ __("unknown") }}
+ {% endif %}
+ </div>
+</div>
diff --git a/src/application/templates/modern/delete_post.twig b/src/application/templates/modern/delete_post.twig
new file mode 100644
index 0000000..e37841a
--- /dev/null
+++ b/src/application/templates/modern/delete_post.twig
@@ -0,0 +1,40 @@
+{% set title = __("Delete post") %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Do you want to delete this post?") }}</h1>
+</div>
+
+<div class="alert alert-danger">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
+ <span>{{ __("Are you sure you want to delete the following post:") }}</span>
+</div>
+{% include "components/post.twig" with {
+ post: ctx.post,
+ postAuthor: ctx.postAuthor,
+ attachments: ctx.attachments,
+ hide_actions: true,
+} %}
+<div class="page-actions">
+ <form action=".#post-{{ ctx.post.id }}" method="get" class="seamless-inline">
+ <input type="hidden" name="_action" value="viewtopic">
+ <input type="hidden" name="topic" value="{{ ctx.post.topicId }}">
+ <button>
+ <svg viewBox="0 0 24 24" class="icon"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
+ <span>{{ __("Keep post") }}</span>
+ </button>
+ </form>
+ <form action="?_action=deletepost" method="post" class="seamless-inline">
+ <input type="hidden" name="post" value="{{ ctx.post.id }}">
+ <input type="hidden" name="confirm" value="{{ ("confirm" ~ ctx.post.id)|hash("sha256", true)|base64_encode }}">
+ <button class="btn-danger">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
+ <span>{{ __("Delete post") }}</span>
+ </button>
+ </form>
+</div>
+
+{% endblock %}
diff --git a/src/application/templates/modern/delete_topic.twig b/src/application/templates/modern/delete_topic.twig
new file mode 100644
index 0000000..51acaa8
--- /dev/null
+++ b/src/application/templates/modern/delete_topic.twig
@@ -0,0 +1,37 @@
+{% set title = __("Delete topic") %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Do you want to delete this topic?") }}</h1>
+</div>
+
+<div class="alert alert-danger">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
+ <span>{{ __("Are you sure you want to delete the topic <em>%topic%</em> including <strong>all posts and attachments</strong>?", {
+ "topic": ctx.topic.title|e("html"),
+ }) }}</span>
+</div>
+
+<div class="page-actions">
+ <form action="." method="get" class="seamless-inline">
+ <input type="hidden" name="_action" value="viewtopic">
+ <input type="hidden" name="topic" value="{{ ctx.topic.id }}">
+ <button>
+ <svg viewBox="0 0 24 24" class="icon"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
+ <span>{{ __("Keep topic") }}</span>
+ </button>
+ </form>
+ <form action="?_action=deletetopic" method="post" class="seamless-inline">
+ <input type="hidden" name="topic" value="{{ ctx.topic.id }}">
+ <input type="hidden" name="confirm" value="{{ ("confirm" ~ ctx.topic.id)|hash("sha256", true)|base64_encode }}">
+ <button class="btn-danger">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
+ <span>{{ __("Delete topic &amp; posts") }}</span>
+ </button>
+ </form>
+</div>
+
+{% endblock %}
diff --git a/src/application/templates/modern/error_page.twig b/src/application/templates/modern/error_page.twig
new file mode 100644
index 0000000..dde4057
--- /dev/null
+++ b/src/application/templates/modern/error_page.twig
@@ -0,0 +1,12 @@
+{% set title = __("Error") %}
+{% extends "base.twig" %}
+
+{% block navbar %}
+ {% if not ctx.skipLoginCheck %}
+ {{ parent() }}
+ {% endif %}
+{% endblock %}
+
+{% block content %}
+ {% include "components/alert_error.twig" with { message: ctx.message } %}
+{% endblock %}
diff --git a/src/application/templates/modern/info_page.twig b/src/application/templates/modern/info_page.twig
new file mode 100644
index 0000000..e243b36
--- /dev/null
+++ b/src/application/templates/modern/info_page.twig
@@ -0,0 +1,12 @@
+{% set title = __("Information") %}
+{% extends "base.twig" %}
+
+{% block navbar %}
+ {% if not ctx.skipLoginCheck %}
+ {{ parent() }}
+ {% endif %}
+{% endblock %}
+
+{% block content %}
+ {% include "components/alert_info.twig" with { message: ctx.message } %}
+{% endblock %}
diff --git a/src/application/templates/modern/login.twig b/src/application/templates/modern/login.twig
new file mode 100644
index 0000000..83b3f59
--- /dev/null
+++ b/src/application/templates/modern/login.twig
@@ -0,0 +1,46 @@
+{% set title = __("Log in") %}
+{% set formId = "login" %}
+{% set formError = getAndClearFormError(formId) %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Log in") }}</h1>
+</div>
+
+<div class="main-form">
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}" method="post">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+
+ <div class="form-group">
+ <label for="i_username">{{ __("Username:") }}</label>
+ <input class="form-control" type="text" id="i_username" name="username" value="{{ lastFormField(formId, "username") }}" required autofocus>
+ </div>
+
+ <div class="form-group">
+ <label for="i_password">{{ __("Password:") }}</label>
+ <input class="form-control" type="password" id="i_password" name="password" required>
+ </div>
+
+ <div class="form-group form-actions">
+ <button class="btn btn-primary" type="submit">{{ __("Log in") }}</button>
+ <a class="btn btn-link" href="?_action=pwreset">{{ __("I forgot my password") }}</a>
+ </div>
+
+ {% if constant("REGISTRATION_ENABLED") %}
+ <div class="form-group form-additional">
+ {{ __("Don't have an account? %link%Register now%/link%", {
+ "link": '<a href="?_action=register">',
+ "/link": '</a>',
+ }) }}
+ </div>
+ {% endif %}
+ </form>
+</div>
+
+{% endblock %}
diff --git a/src/application/templates/modern/new_password.twig b/src/application/templates/modern/new_password.twig
new file mode 100644
index 0000000..64854b6
--- /dev/null
+++ b/src/application/templates/modern/new_password.twig
@@ -0,0 +1,38 @@
+{% set title = __("Reset password") %}
+{% set formId = "pwnew" %}
+{% set formError = getAndClearFormError(formId) %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Reset password") }}</h1>
+</div>
+
+<div class="main-form">
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}" method="post">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+ <input type="hidden" name="token" value="{{ g.get.token }}">
+ <input type="hidden" name="sig" value="{{ g.get.sig }}">
+
+ <div class="form-group">
+ <label for="i_new_password">{{ __("New password:") }}</label>
+ <input class="form-control" type="password" id="i_new_password" name="new_password" required autofocus>
+ </div>
+
+ <div class="form-group">
+ <label for="i_retype_password">{{ __("Retype password:") }}</label>
+ <input class="form-control" type="password" id="i_retype_password" name="retype_password" required>
+ </div>
+
+ <div class="form-group form-actions">
+ <button class="btn btn-primary" type="submit">{{ __("Set new password") }}</button>
+ </div>
+ </form>
+</div>
+
+{% endblock %}
diff --git a/src/application/templates/modern/new_topic.twig b/src/application/templates/modern/new_topic.twig
new file mode 100644
index 0000000..9cd18c9
--- /dev/null
+++ b/src/application/templates/modern/new_topic.twig
@@ -0,0 +1,39 @@
+{% set title = __("New topic") %}
+{% set formId = "newtopic" %}
+{% set formError = getAndClearFormError(formId) %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("New topic") }}</h1>
+</div>
+
+{% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+{% endif %}
+<form action="{{ g.server.REQUEST_URI }}" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+ <div class="form-group">
+ <label for="i_title">{{ __("Topic title:") }}</label>
+ <input type="text" id="i_title" name="title" value="{{ lastFormField(formId, "title") }}" required autofocus>
+ </div>
+ <div class="form-group">
+ <label for="i_message">{{ __("Message:") }}</label>
+ {% include "components/richtext_editor.twig" with { id: "i_message", name: "message" } %}
+ </div>
+ <div class="form-group">
+ <label for="i_files">{{ __("Attachments: <small>(max. %max_attachment_count% files, max. %max_attachment_size% MiB each)</small>", {
+ "max_attachment_count": constant("MAX_ATTACHMENT_COUNT"),
+ "max_attachment_size": constant("MAX_ATTACHMENT_SIZE") // (2**20),
+ }) }}</label>
+ <input type="file" name="files[]" id="i_files" multiple accept="*/*">
+ </div>
+ <button type="submit" class="btn btn-success">
+ <span>{{ __("Create topic") }}</span>
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/></svg>
+ </button>
+</form>
+
+{% endblock %}
diff --git a/src/application/templates/modern/password_reset.twig b/src/application/templates/modern/password_reset.twig
new file mode 100644
index 0000000..4ed9bbe
--- /dev/null
+++ b/src/application/templates/modern/password_reset.twig
@@ -0,0 +1,38 @@
+{% set title = __("Reset password") %}
+{% set formId = "pwreset" %}
+{% set formError = getAndClearFormError(formId) %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Reset password") }}</h1>
+</div>
+
+<div class="main-form">
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}" method="post">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+
+ <div class="form-group">
+ <label for="i_username">{{ __("Email address:") }}</label>
+ <input class="form-control" type="email" id="i_email" name="email" value="{{ lastFormField(formId, "email") }}" required autofocus>
+ </div>
+
+ <div class="form-group form-actions">
+ <button class="btn btn-primary" type="submit">{{ __("Reset password") }}</button>
+ </div>
+
+ <div class="form-group form-additional">
+ {{ __("I know my password and I want to %link%log in%/link%!", {
+ "link": '<a href="?_action=auth">',
+ "/link": '</a>',
+ }) }}
+ </div>
+ </form>
+</div>
+
+{% endblock %}
diff --git a/src/application/templates/modern/register.twig b/src/application/templates/modern/register.twig
new file mode 100644
index 0000000..8e7dfe5
--- /dev/null
+++ b/src/application/templates/modern/register.twig
@@ -0,0 +1,92 @@
+{% set title = __("Register") %}
+{% set formId = "register" %}
+{% set formError = getAndClearFormError(formId) %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Register") }}</h1>
+</div>
+
+<div class="main-form">
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}" method="post">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+
+ <div class="form-group" id="group0">
+ <label for="i_username">{{ __("Username:") }}</label>
+ <input class="form-control" id="i_username" type="text" name="username" value="" required>
+ </div>
+
+ <div class="form-group" id="group1">
+ <label for="i_username">{{ __("Username:") }}</label>
+ <input class="form-control" id="i_username" type="text" name="df82a9bc21" value="{{ lastFormField(formId, "df82a9bc21") }}" required autofocus>
+ </div>
+
+ <div class="form-group" id="group2">
+ <label for="i_display_name">{{ __("Display name:") }}</label>
+ <input class="form-control" id="i_display_name" type="text" name="display_name" value="{{ lastFormField(formId, "display_name") }}" required>
+ </div>
+
+ <div class="form-group" id="group3">
+ <label for="i_password">{{ __("Choose password:") }}</label>
+ <input class="form-control" id="i_password" type="password" name="password" required>
+ </div>
+
+ <div class="form-group" id="group4">
+ <label for="i_password_retype">{{ __("Repeat password:") }}</label>
+ <input class="form-control" id="i_password_retype" type="password" name="password_retype" required>
+ </div>
+
+ <div class="form-group" id="group5">
+ <label for="i_email">{{ __("Email address:") }}</label>
+ <input class="form-control" id="i_email" type="email" name="email" value="{{ lastFormField(formId, "email") }}" required>
+ </div>
+
+ <div class="form-group" id="group6">
+ <label for="i_email">{{ __("CAPTCHA:") }}</label>
+ <div class="text-center">
+ <img src="?_action=captcha&amp;t={{ "now"|date("Uv") }}" alt="CAPTCHA" width="192" height="48" id="captcha-img">
+ </div>
+ <div class="spring-row">
+ <div class="spring-fill">
+ <input type="text" name="captcha" id="i_captcha" class="form-control" required>
+ </div>
+ <div class="spring-fit">
+ <button class="btn btn-iconic" type="button" id="btn-refresh-captcha" title="{{ __("New CAPTCHA") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group form-actions">
+ <button class="btn btn-primary" type="submit">{{ __("Register now") }}</button>
+ </div>
+
+ <div class="form-group form-additional">
+ {{ __("Already have an account? %link%Sign in now%/link%", {
+ "link": '<a href="?_action=auth">',
+ "/link": '</a>',
+ }) }}
+ </div>
+ </form>
+</div>
+
+<script>
+document.addEventListener("DOMContentLoaded", function() {
+ document.querySelector("#btn-refresh-captcha").addEventListener("click", function() {
+ document.querySelector("#captcha-img").src = "?_action=captcha&t=" + new Date().getTime().toString();
+ });
+ (function($$) {
+ $$.disabled = true;
+ $$.required = false;
+ })(document.querySelector("#i_username"));
+});
+</script>
+
+{% endblock %}
diff --git a/src/application/templates/modern/search.twig b/src/application/templates/modern/search.twig
new file mode 100644
index 0000000..3546f41
--- /dev/null
+++ b/src/application/templates/modern/search.twig
@@ -0,0 +1,69 @@
+{% set title = __("Search") %}
+{% set formId = "search" %}
+{% set formError = getAndClearFormError(formId) %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header">
+ <h1>{{ __("Search") }}</h1>
+</div>
+
+{% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+{% endif %}
+
+<form action="." method="get">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+ <input type="hidden" name="_action" value="search">
+ <div class="spring-row">
+ <div class="spring-fill">
+ <input type="search" id="i_query" name="query" value="{{ lastFormField(formId, "query")|default(g.get.query)|default("") }}" required autofocus placeholder="{{ __("Enter your search query...") }}">
+ </div>
+ <div class="spring-fit">
+ <button class="btn btn-primary" type="submit">{{ __("Search") }}</button>
+ </div>
+ </div>
+</form>
+
+{% if g.get.query is defined and g.get.query is not null and g.get.query != "" %}
+ {% if ctx.posts|length > 0 %}
+ <p>{{ __("%result_count% result(s) in %search_duration% second(s)", {
+ "result_count": ctx.posts|length,
+ "search_duration": ctx.search_duration|number_format(2, __(".", context: "Number formatting"), __(",", context: "Number formatting")),
+ }) }}</p>
+ <div class="post-list">
+ {% for post in ctx.posts|filter(p => not p.deleted) %}
+ {% set hasAttachments = ctx.attachments[post.id]|length > 0 %}
+ {% set postAuthor = ctx.users[post.authorId] %}
+ <a href="?_action=viewtopic&amp;topic={{ post.topicId|url_encode }}#post-{{ post.id|url_encode }}" class="_item">
+ <h4 class="_heading">
+ {% if hasAttachments %}
+ <span class="badge">
+ <svg viewBox="0 0 24 24" class="icon"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
+ </span>
+ {% endif %}
+ {{ renderPostSummary(post.content) }}<br>
+ </h4>
+ <span class="text-muted _text">{{ __("posted by %author% on %post_date% in %topic%", {
+ "author": '<em>' ~ (postAuthor ? postAuthor.displayName : __("unknown"))|e("html") ~ '</em>',
+ "post_date": '<span class="_time">' ~ post.postDate.format("c")|e("html") ~ '</span>',
+ "topic": '<em>'
+ ~ (ctx.topics[post.topicId].isLocked ? '<svg viewBox="0 0 24 24" class="icon text-muted"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> ' : '')
+ ~ (ctx.topics[post.topicId] ? ctx.topics[post.topicId].title : null)|default(__("unknown"))|e("html") ~ '</em>',
+ }) }}</span>
+ </a>
+ {% endfor %}
+ </div>
+ {% else %}
+ <div class="result-alert">
+ <div class="alert alert-info">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
+ <span>{{ __("No results for this search") }}</span>
+ </div>
+ </div>
+ {% endif %}
+{% endif %}
+
+{% endblock %}
diff --git a/src/application/templates/modern/view_topic.twig b/src/application/templates/modern/view_topic.twig
new file mode 100644
index 0000000..733ce1b
--- /dev/null
+++ b/src/application/templates/modern/view_topic.twig
@@ -0,0 +1,367 @@
+{% set canReply =
+ not ctx.topic.isLocked
+ and currentUser is not null
+ and currentUser.hasPermission(permission("CREATE_OWN_POST")) %}
+
+{% set canEdit =
+ currentUser is not null and (
+ (
+ ctx.topicAuthor is not null
+ and currentUser.id == ctx.topicAuthor.id
+ and ctx.topicAuthor.hasPermission(permission("EDIT_OWN_TOPIC"))
+ )
+ or currentUser.hasPermission(permission("EDIT_OTHER_TOPIC"))
+ ) %}
+
+{% set couldEditPost =
+ currentUser is not null
+ and (
+ currentUser.hasPermission(permission("EDIT_OWN_POST"))
+ or currentUser.hasPermission(permission("EDIT_OTHER_POST"))
+ ) %}
+
+{% set canDelete =
+ currentUser is not null and (
+ (
+ ctx.topicAuthor is not null
+ and currentUser.id == ctx.topicAuthor.id
+ and ctx.topicAuthor.hasPermission(permission("DELETE_OWN_TOPIC"))
+ )
+ or currentUser.hasPermission(permission("DELETE_OTHER_TOPIC"))
+ ) %}
+
+{% set title = ctx.topic.title %}
+{% extends "base.twig" %}
+
+{% block content %}
+
+{% if couldEditPost %}
+ <div class="modal fade" tabindex="-1" role="dialog" id="diag-edit-post">
+ <form class="modal-dialog" role="document" action="?_action=updatepost" method="post">
+ <input type="hidden" id="i_edit_post" name="post">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="btn-danger close btn-iconic" data-dismiss="modal" aria-label="Close">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
+ </button>
+ <h4 class="modal-title">{{ __("Edit post") }}</h4>
+ </div>
+ <div class="modal-body edit-post-wrapper">
+ <label class="sr-only" for="i_edit_message">{{ __("Message:") }}</label>
+ {% include "components/richtext_editor.twig" with { id: "i_edit_message", name: "message" } %}
+ </div>
+ <div class="modal-footer">
+ <button type="button" data-dismiss="modal">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
+ <span>{{ __("Cancel") }}</span>
+ </button>
+ <button type="submit" class="btn btn-success">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
+ <span>{{ __("Save changes") }}</span>
+ </button>
+ </div>
+ </div>
+ </form>
+ </div>
+{% endif %}
+{% if currentUser is null %}
+ <div class="modal fade" tabindex="-1" role="dialog" id="diag-cant-view-attachment">
+ <div class="modal-dialog modal-danger" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title"><svg viewBox="0 0 24 24" class="icon icon-in-text"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg> {{ __("Permission denied") }}</h4>
+ </div>
+ <div class="modal-body">
+ {{ __("You must be logged in to view attachments") }}
+ </div>
+ <div class="modal-footer">
+ <button type="button" data-dismiss="modal">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
+ <span>{{ __("Close") }}</span>
+ </button>
+ <a href="?_action=auth&amp;next={{ g.server.REQUEST_URI|url_encode }}" class="btn btn-success">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
+ <span>{{ __("Log in") }}</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <script>
+ document.addEventListener("DOMContentLoaded", function() {
+ document.querySelectorAll(".attachment").forEach(e => e.addEventListener("click", function(ev) {
+ ev.preventDefault();
+ document.querySelector("#diag-cant-view-attachment").classList.remove("fade");
+ }));
+ });
+ </script>
+{% else %}
+ <div class="modal fade" tabindex="-1" role="dialog" id="diag-image-attachment">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="btn-danger close btn-iconic" data-dismiss="modal" aria-label="Close">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
+ </button>
+ <h4 class="modal-title">{{ __("Attachment") }}</h4>
+ </div>
+ <div class="modal-body">
+ <img class="image-attachment-view attachment-view" id="image-attachment-view" alt="">
+ </div>
+ <div class="modal-footer">
+ <a href="" download id="image-attachment-dl-btn" class="btn">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
+ <span>{{ __("Download") }}</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal fade" tabindex="-1" role="dialog" id="diag-video-attachment">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="btn-danger close btn-iconic" data-dismiss="modal" aria-label="Close">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
+ </button>
+ <h4 class="modal-title">{{ __("Attachment") }}</h4>
+ </div>
+ <div class="modal-body">
+ <video class="video-attachment-view attachment-view" id="video-attachment-view" controls></video>
+ </div>
+ <div class="modal-footer">
+ <a href="" download id="video-attachment-dl-btn" class="btn">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
+ <span>{{ __("Download") }}</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <script>
+ document.addEventListener("DOMContentLoaded", function() {
+ document.querySelectorAll(".image-attachment:not(.video-attachment)").forEach(e => e.addEventListener("click", function(ev) {
+ ev.preventDefault();
+ const attUrl = "?_action=attachment&attachment=" + encodeURIComponent(e.dataset.attachmentId);
+ document.querySelector("#image-attachment-view").src = attUrl;
+ document.querySelector("#image-attachment-dl-btn").href = attUrl;
+ document.querySelector("#diag-image-attachment").classList.remove("fade");
+ }));
+ document.querySelectorAll(".image-attachment.video-attachment").forEach(e => e.addEventListener("click", function(ev) {
+ ev.preventDefault();
+ const attUrl = "?_action=attachment&attachment=" + encodeURIComponent(e.dataset.attachmentId);
+ document.querySelector("#video-attachment-view").src = attUrl;
+ document.querySelector("#video-attachment-dl-btn").href = attUrl;
+ document.querySelector("#diag-video-attachment").classList.remove("fade");
+ }));
+ document.querySelector("#diag-video-attachment").addEventListener("hidemodal", function() {
+ document.querySelector("#video-attachment-view").pause();
+ });
+ });
+ </script>
+{% endif %}
+
+{% set formError = getAndClearFormError("updateTopic") %}
+{% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+{% endif %}
+{% set formError = getAndClearFormError("lockTopic") %}
+{% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+{% endif %}
+{% set formError = null %}
+
+<div class="page-header">
+ <div id="displayHeading">
+ <div role="heading" class="h1 seamless-inline">
+ {% if ctx.topic.isLocked %}
+ <svg viewBox="0 0 24 24" class="icon text-muted"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
+ {% endif %}
+ {{ ctx.topic.title }}
+ <div class="title-controls pull-right text-normal">
+ {% if canEdit and not ctx.topic.isLocked %}
+ <button id="btn-edit-title" class="btn js-only btn-iconic" title="{{ __("Edit title") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>
+ </button>
+ {% endif %}
+ {% if canReply %}
+ <button id="btn-reply" class="btn js-only btn-iconic" title="{{ __("Reply") }}">
+ <svg viewBox="0 0 24 24" class="icon"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
+ </button>
+ {% endif %}
+ {% if canEdit %}
+ {% if ctx.topic.isLocked %}
+ <form action="?_action=locktopic" method="post">
+ <input type="hidden" name="topic" value="{{ ctx.topic.id }}">
+ <input type="hidden" name="locked" value="false">
+ <button type="submit" class="btn btn-success btn-iconic" title="{{ __("Unlock topic") }}">
+ <svg viewBox="0 0 24 24" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
+ </button>
+ </form>
+ {% else %}
+ <form action="?_action=locktopic" method="post">
+ <input type="hidden" name="topic" value="{{ ctx.topic.id }}">
+ <input type="hidden" name="locked" value="true">
+ <button type="submit" class="btn btn-warning btn-iconic" title="{{ __("Lock topic") }}">
+ <svg viewBox="0 0 24 24" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
+ </button>
+ </form>
+ {% endif %}
+ {% endif %}
+ {% if canDelete %}
+ <form action="?_action=deletetopic" method="post">
+ <input type="hidden" name="topic" value="{{ ctx.topic.id }}">
+ <button type="submit" class="btn btn-danger btn-iconic" title="{{ __("Delete topic") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
+ </button>
+ </form>
+ {% endif %}
+ </div>
+ </div>
+ {{ __("Started by %user% on %date%", {
+ "user": (ctx.topicAuthor is not null) ? ('<a href="?_action=viewuser&amp;user=' ~ ctx.topicAuthor.id|url_encode|e("html") ~ '">' ~ ctx.topicAuthor.displayName|e("html") ~ '</a>') : __("(deleted)"),
+ "date": '<span class="_time">' ~ ctx.topic.creationDate.format("c")|e("html") ~ '</span>',
+ }) }}
+ </div>
+ {% if canEdit %}
+ <form action="?_action=updatetopic" method="post" id="editHeading" hidden>
+ <input type="hidden" name="topic" value="{{ ctx.topic.id }}">
+ <div class="spring-row">
+ <div class="spring-fill h1">
+ <input type="text" name="title" id="i_edit_title" data-original-value="{{ ctx.topic.title }}" value="{{ ctx.topic.title }}">
+ </div>
+ <div class="spring-fit controls">
+ <button type="button" class="btn-iconic" id="topicTitleEditCancel" title="{{ __("Cancel") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
+ </button>
+ <button type="submit" class="btn-iconic btn-success" title="{{ __("Save changes") }}">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
+ </button>
+ </div>
+ </div>
+ </form>
+ {% endif %}
+</div>
+{% if canEdit %}
+<script>
+document.addEventListener("DOMContentLoaded", function() {
+ document.querySelector("#btn-edit-title").addEventListener("click", function() {
+ document.querySelector("#displayHeading").hidden = true;
+ document.querySelector("#editHeading").hidden = false;
+ const $title = document.querySelector("#i_edit_title")
+ $title.value = $title.dataset.originalValue;
+ $title.focus();
+ $title.selectionStart = $title.selectionEnd = $title.value.length;
+ });
+ document.querySelector("#topicTitleEditCancel").addEventListener("click", function() {
+ document.querySelector("#displayHeading").hidden = false;
+ document.querySelector("#editHeading").hidden = true;
+ });
+});
+</script>
+{% endif %}
+{% if couldEditPost %}
+<script>
+document.addEventListener("DOMContentLoaded", function() {
+ document.querySelectorAll("._edit-post").forEach(e => e.addEventListener("click", function() {
+ const $post = document.querySelector("[data-post-id='" + e.dataset.postId + "']");
+ const $edit = document.querySelector("#i_edit_message");
+ $edit.style.removeProperty("height");
+ $edit.value = $post.dataset.text;
+ document.querySelector("#i_edit_post").value = e.dataset.postId;
+ document.querySelector("#diag-edit-post").classList.remove("fade");
+ }));
+});
+</script>
+{% endif %}
+<script>
+{% if canReply %}
+document.addEventListener("DOMContentLoaded", function() {
+ function focusReplyBox() {
+ const msgInput = document.querySelector("#i_message");
+ msgInput.scrollIntoView();
+ msgInput.focus();
+ }
+ document.querySelector("#btn-reply").addEventListener("click", function() {
+ focusReplyBox();
+ });
+ document.querySelectorAll("._reply-post").forEach(e => e.addEventListener("click", function() {
+ const text = document.querySelector("#post-" + e.dataset.postId).dataset.text;
+ const lines = text.split("\n");
+ let val = document.querySelector("#i_message").value;
+ for (let i = 0; i < lines.length; ++i)
+ val += "\n> " + lines[i];
+ val += "\n\n";
+ document.querySelector("#i_message").value = val.replace(/^\n+/, "");
+ focusReplyBox();
+ }));
+});
+{% endif %}
+</script>
+
+{% for item in ctx.allItems %}
+ {% if item.type == "post" %}
+ {% include "components/post.twig" with {
+ post: item.post,
+ postAuthor: item.postAuthor,
+ topicAuthor: item.topicAuthor,
+ topic: item.topic,
+ attachments: item.attachments,
+ hide_actions: false,
+ hide_pfp: false,
+ } %}
+ {% elseif item.type == "logMessage" %}
+ {% include "components/topic_log.twig" with {
+ type: item.type,
+ logMessage: item.logMessage,
+ postAuthor: item.postAuthor,
+ topicAuthor: item.topicAuthor,
+ topic: item.topic,
+ hide_actions: false,
+ hide_pfp: false,
+ } %}
+ {% endif %}
+{% endfor %}
+
+{% if ctx.topic.isLocked %}
+ <div class="topic-info-box warning topic-locked">
+ <svg viewBox="0 0 24 24" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
+ <em>{{ __("This topic has been locked") }}</em>
+ </div>
+{% elseif currentUser is not null %}
+ {% set formId = "addpost" %}
+ <h3 id="form">{{ __("Reply to this topic") }}</h3>
+ {% set formError = getAndClearFormError(formId) %}
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}#form" method="post" enctype="multipart/form-data" class="post-reply">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+ <div class="form-group">
+ <label for="i_message" class="sr-only">{{ __("Message:") }}</label>
+ {% include "components/richtext_editor.twig" with { id: "i_message", name: "message" } %}
+ </div>
+ <div class="form-group">
+ <label for="i_files">{{ __("Attachments: <small>(max. %max_attachment_count% files, max. %max_attachment_size% MiB each)</small>", {
+ "max_attachment_count": constant("MAX_ATTACHMENT_COUNT"),
+ "max_attachment_size": constant("MAX_ATTACHMENT_SIZE") // (2**20),
+ }) }}</label>
+ <input type="file" name="files[]" id="i_files" multiple accept="*/*">
+ </div>
+ <button type="submit" class="btn btn-success">
+ <span>{{ __("Post reply") }}</span>
+ <svg viewBox="0 0 24 24" class="icon"><path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/></svg>
+ </button>
+ </form>
+{% else %}
+ <div class="topic-info-box success">
+ <h3>{{ __("Log in to reply to this topic") }}</h3>
+ <a href="?_action=auth&amp;next={{ g.server.REQUEST_URI|url_encode }}" class="btn btn-success">
+ <svg viewBox="0 0 24 24" class="icon"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
+ <span>{{ __("Log in") }}</span>
+ </a>
+ </div>
+{% endif %}
+
+
+{% endblock %}
diff --git a/src/application/templates/modern/view_topics.twig b/src/application/templates/modern/view_topics.twig
new file mode 100644
index 0000000..1f24863
--- /dev/null
+++ b/src/application/templates/modern/view_topics.twig
@@ -0,0 +1,28 @@
+{% extends "base.twig" %}
+
+{% block content %}
+
+{% if currentUser.hasPermission(permission("CREATE_OWN_TOPIC")) %}
+ <div class="actions">
+ <a href="?_action=newtopic" class="btn">
+ <svg viewBox="0 0 24 24" class="icon"><path d="m5 19-2 2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2"/><path d="M9 10h6"/><path d="M12 7v6"/><path d="M9 17h6"/></svg>
+ <span>{{ __("New topic") }}</span>
+ </a>
+ </div>
+{% endif %}
+
+<div class="post-list">
+ {% for topic in ctx.topics %}
+ <a class="_item" href="?_action=viewtopic&amp;topic={{ topic.id|url_encode }}">
+ <h4 class="_heading">
+ {% if topic.isLocked %}
+ <svg viewBox="0 0 24 24" class="icon text-muted"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
+ {% endif %}
+ {{ topic.title }}
+ </h4>
+ <p class="_text _time">{{ topic.creationDate.format("c") }}</p>
+ </a>
+ {% endfor %}
+</div>
+
+{% endblock %}
diff --git a/src/application/templates/modern/view_user.twig b/src/application/templates/modern/view_user.twig
new file mode 100644
index 0000000..f2b8440
--- /dev/null
+++ b/src/application/templates/modern/view_user.twig
@@ -0,0 +1,196 @@
+{% set canEdit =
+ currentUser is not null
+ and (
+ (
+ ctx.user.id == currentUser.id
+ and currentUser.hasPermission(permission("EDIT_OWN_USER"))
+ )
+ or currentUser.hasPermission(permission("EDIT_OTHER_USER"))
+ ) %}
+
+{% set isOwnProfile =
+ currentUser is not null
+ and currentUser.id == ctx.user.id %}
+
+{% set sUserPossessive = isOwnProfile ? "Your posts" : "%display_name%'s posts" %}
+
+{% set emailPending = isOwnProfile and ctx.user.pendingEmail is not null %}
+
+{% set title = ctx.user.displayName %}
+
+{% extends "base.twig" %}
+
+{% block content %}
+
+<div class="page-header profile-header">
+ <img class="_image" src="?_action=profilepicture&amp;user={{ ctx.user.id|url_encode }}" alt="{{ __("Profile picture") }}" width="64" height="64">
+ <div class="h1 _name">
+ {{ ctx.user.displayName }}
+ {% if isOwnProfile %}
+ <svg viewBox="0 0 24 24" class="icon is-you icon-in-text"><title>{{ __("You") }}</title><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
+ {% endif %}
+ </div>
+ <span class="_info">
+ @{{ ctx.user.name }} &bull; <span class="text-muted">{{ __("Member since %join_date%", {
+ "join_date": '<span class="_date">' ~ (ctx.dateJoined.format("c")|e("html")) ~ '</span>',
+ }) }}</span>
+ </span>
+</div>
+
+{% if canEdit %}
+<div class="profile-edit-view">
+<div class="_posts">
+{% endif %}
+
+<h3>{{ __(sUserPossessive, {
+ "display_name": ctx.user.displayName|e("html"),
+}) }}</h3>
+
+{% if ctx.posts|length > 0 %}
+ <div class="post-list">
+ {% for post in ctx.posts %}
+ <a href="?_action=viewtopic&amp;topic={{ post.topicId|url_encode }}#post-{{ post.id|url_encode }}" class="_item">
+ <h4 class="_heading">
+ {% if hasAttachments %}
+ <span class="badge">
+ <svg viewBox="0 0 24 24" class="icon"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
+ </span>
+ {% endif %}
+ {{ renderPostSummary(post.content) }}<br>
+ </h4>
+ <span class="text-muted _text">{{ __("posted on %post_date% in %topic%", {
+ "post_date": '<span class="_time">' ~ post.postDate.format("c")|e("html") ~ '</span>',
+ "topic": '<em>' ~
+ (ctx.topics[post.topicId] is not null and ctx.topics[post.topicId].isLocked ? '<svg viewBox="0 0 24 24" class="icon text-muted"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> ' : '') ~
+ (ctx.topics[post.topicId] is not null ? ctx.topics[post.topicId].title : __("unknown"))|e("html") ~ '</em>',
+ }) }}</span>
+ </a>
+ {% endfor %}
+ </div>
+{% else %}
+ <div class="result-alert">
+ <div class="alert alert-info">
+ <svg viewBox="0 0 24 24" class="icon"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
+ <span>{{ __("This user has not posted anything yet") }}</span>
+ </div>
+ </div>
+{% endif %}
+
+{% if canEdit %}
+</div> <!-- _posts -->
+
+<div class="_edit">
+ <h3>{{ __("Edit profile") }}</h3>
+ {% set formId = "update_profile" %}
+ {% set formError = getAndClearFormError(formId) %}
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+ <div class="form-group">
+ <label for="i_display_name">{{ __("Display name:") }}</label>
+ <input required class="form-control" type="text" name="display_name" id="i_display_name" value="{{ ctx.user.displayName }}">
+ </div>
+ <div class="form-group">
+ <label for="i_name">{{ __("Username:") }}</label>
+ {% if ctx.lastNameChangeTooRecent %}
+ <input class="form-control" type="text" id="i_name" value="{{ ctx.user.name }}" disabled>
+ <small class="text-danger"><strong>{{ __("You can only change your username every 30 days!") }}</strong></small>
+ {% else %}
+ <input required class="form-control" type="text" name="name" id="i_name" value="{{ ctx.user.name }}">
+ {% endif %}
+ </div>
+ <div class="form-group">
+ <label for="i_email">{{ __("Email address:") }}</label>
+ {% if emailPending %}
+ <input class="form-control" type="email" id="i_email" value="{{ ctx.user.email }}" disabled>
+ {% else %}
+ <input required class="form-control" type="email" id="i_email" name="email" value="{{ ctx.user.email }}">
+ {% endif %}
+ </div>
+ <div class="form-group">
+ <label>{{ __("Profile picture:") }}</label>
+ <div class="radio {{ ctx.user.profilePicture is empty ? " disabled text-muted" }}">
+ <label>
+ <input type="radio" name="pfp_action" id="pfp_action_1" value="keep"{{ ctx.user.profilePicture is not empty ? ' checked' : ' disabled' }}>
+ {{ __("Keep current profile picture") }}
+ </label>
+ </div>
+ <div class="radio">
+ <label>
+ <input type="radio" name="pfp_action" id="pfp_action_2" value="remove"{{ ctx.user.profilePicture is empty ? ' checked' : '' }}>
+ {% if ctx.user.profilePicture is empty %}
+ {{ __("No profile picture") }}
+ {% else %}
+ {{ __("Remove profile picture") }}
+ {% endif %}
+ </label>
+ </div>
+ <div class="radio">
+ <label>
+ <input type="radio" name="pfp_action" value="replace" id="pfp_action_3">
+ {{ __("Upload new profile picture") }}
+ </label>
+ </div>
+ <input type="file" name="pfp" id="i_pfp" accept="image/png,image/jpeg">
+ </div>
+ <div class="form-group form-actions">
+ <button type="submit" class="btn btn-primary">{{ __("Save changes") }}</button>
+ </div>
+ </form>
+ {% if isOwnProfile %}
+ <h3>{{ __("Change password") }}</h3>
+ {% set formId = "update_password" %}
+ {% set formError = getAndClearFormError(formId) %}
+ {% if formError %}
+ {% include "components/alert_error.twig" with { message: formError } %}
+ {% endif %}
+ <form action="{{ g.server.REQUEST_URI }}" method="post">
+ <input type="hidden" name="form_id" value="{{ formId }}">
+ <div class="form-group">
+ <label for="i_current_password">{{ __("Current password:") }}</label>
+ <input autocomplete="current-password" required class="form-control" type="password" name="current_password" id="i_current_password" required>
+ </div>
+ <div class="form-group">
+ <label for="i_new_password">{{ __("New password:") }}</label>
+ <input autocomplete="new-password" required class="form-control" type="password" name="new_password" id="i_new_password" required>
+ </div>
+ <div class="form-group">
+ <label for="i_retype_password">{{ __("Retype password:") }}</label>
+ <input autocomplete="new-password" required class="form-control" type="password" name="retype_password" id="i_retype_password" required>
+ </div>
+ <div class="form-group form-actions">
+ <button type="submit" class="btn btn-primary">{{ __("Change password") }}</button>
+ </div>
+ </form>
+ {% endif %}
+</div> <!-- ._edit -->
+</div> <!-- .profile-edit-view -->
+{% endif %}
+
+{% if canEdit %}
+<script>
+document.addEventListener("DOMContentLoaded", function() {
+ const $i_pfp = document.querySelector("#i_pfp");
+ function _hide() {
+ $i_pfp.hidden = true;
+ $i_pfp.disabled = true;
+ $i_pfp.required = false;
+ }
+ _hide();
+ setTimeout(_hide, 10);
+ document.querySelectorAll("[name='pfp_action']").forEach(e => "change input check click".split(" ").forEach(n => e.addEventListener(n, () => {
+ if (document.querySelector("#pfp_action_3").checked) {
+ $i_pfp.hidden = false;
+ $i_pfp.disabled = false;
+ $i_pfp.required = true;
+ } else {
+ _hide();
+ }
+ })));
+});
+</script>
+{% endif %}
+
+{% endblock %}
diff --git a/src/application/themes/modern/theme.json b/src/application/themes/modern/theme.json
new file mode 100644
index 0000000..f545766
--- /dev/null
+++ b/src/application/themes/modern/theme.json
@@ -0,0 +1,11 @@
+{
+ "$format": 1,
+ "version": "1.0.0",
+ "id": "modern",
+ "name": "Modern",
+ "author": "Jonas Kohl",
+ "template": "modern",
+ "files": [
+ "../../../ui/theme-files/modern/theme.css"
+ ]
+}
diff --git a/src/ui/theme-files/modern/InterVariable-Italic.woff2 b/src/ui/theme-files/modern/InterVariable-Italic.woff2
new file mode 100644
index 0000000..f22ec25
--- /dev/null
+++ b/src/ui/theme-files/modern/InterVariable-Italic.woff2
Binary files differ
diff --git a/src/ui/theme-files/modern/InterVariable.woff2 b/src/ui/theme-files/modern/InterVariable.woff2
new file mode 100644
index 0000000..22a12b0
--- /dev/null
+++ b/src/ui/theme-files/modern/InterVariable.woff2
Binary files differ
diff --git a/src/ui/theme-files/modern/theme.css b/src/ui/theme-files/modern/theme.css
new file mode 100644
index 0000000..094e7fc
--- /dev/null
+++ b/src/ui/theme-files/modern/theme.css
@@ -0,0 +1,970 @@
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url('/ui/theme-files/modern/InterVariable.woff2?v=4.0') format('woff2');
+}
+@font-face {
+ font-family: Inter;
+ font-style: italic;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url('/ui/theme-files/modern/InterVariable-Italic.woff2?v=4.0') format('woff2');
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+:root {
+ color-scheme: light dark;
+ scroll-behavior: smooth;
+ accent-color: var(--color--primary);
+
+ --header-height: 52px;
+ --border-radius: 0;
+
+ --font-family--sans-serif: 'Inter', ui-sans-serif, system-ui, sans-serif;
+ --font-family--monospace: ui-monospace, monospace;
+ --font-size--base: 11pt;
+
+ --color--primary: #07d;
+ --color--info: #0ac;
+ --color--success: #0c8;
+ --color--warning: #d90;
+ --color--danger: #c08;
+ --color--muted: #707d8a;
+
+ --color--background: #fff;
+ --color--text: #000;
+ --color--links: var(--color--primary);
+
+ --color--btn--default-bg: #000;
+ --color--btn--default-fg: #fff;
+
+ --color--btn--primary-bg: var(--color--primary);
+ --color--btn--primary-fg: #fff;
+
+ --color--btn--info-bg: var(--color--info);
+ --color--btn--info-fg: #000;
+
+ --color--btn--success-bg: var(--color--success);
+ --color--btn--success-fg: #000;
+
+ --color--btn--warning-bg: var(--color--warning);
+ --color--btn--warning-fg: #000;
+
+ --color--btn--danger-bg: var(--color--danger);
+ --color--btn--danger-fg: #fff;
+
+ --widget--border-width: 1px;
+ --widget--border-color: #bbb;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color--primary: #9cf;
+ --color--info: #9ef;
+ --color--success: #9fa;
+ --color--warning: #fd9;
+ --color--danger: #f9b;
+ --color--muted: #97a3af;
+
+ --color--background: #0b0b0b;
+ --color--text: #e1e1e1;
+
+ --color--btn--default-bg: #fff;
+ --color--btn--default-fg: #000;
+
+ --color--btn--primary-fg: #000;
+ --color--btn--danger-fg: #000;
+
+ --widget--border-color: #3b3b3b;
+
+ select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23e1e1e1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ }
+ }
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ min-height: 100%;
+
+ background: var(--color--background);
+ color: var(--color--text);
+
+ display: grid;
+ max-inline-size: 1000px;
+ margin-inline: auto;
+ margin-block: 0;
+ grid-template: "header" max-content
+ "main" minmax(0, 1fr)
+ "footer" max-content
+ / minmax(0, 1fr);
+ font: var(--font-size--base) var(--font-family--sans-serif);
+ font-optical-sizing: auto;
+ box-shadow: calc(var(--widget--border-width) * -1) 0 0 var(--widget--border-color),
+ var(--widget--border-width) 0 0 var(--widget--border-color);
+}
+
+[hidden] {
+ display: none !important;
+}
+
+h1,
+.h1 {
+ font-size: 2em;
+ font-weight: 200;
+}
+
+a {
+ color: var(--color--links);
+}
+
+:focus-visible {
+ outline: 1px solid var(--_focus-color, var(--_bg, var(--color--primary)));
+ outline-offset: 2px;
+}
+
+header {
+ grid-area: header;
+ display: grid;
+ grid-template: "brand nav" minmax(0, 1fr)
+ / max-content minmax(0, 1fr);
+ /* padding: 16px 8px; */
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ position: sticky;
+ top: 0;
+ background: var(--color--background);
+ z-index: 3;
+ height: var(--header-height);
+ align-items: stretch;
+}
+#brand {
+ display: block;
+ grid-area: brand;
+ align-content: center;
+ margin-left: 16px;
+ font-weight: 400;
+ text-decoration: none;
+ color: inherit;
+}
+nav {
+ display: flex;
+ justify-content: end;
+ align-items: stretch;
+ grid-area: nav;
+ padding-right: 8px;
+ >p {
+ margin: 0;
+ align-content: center;
+ }
+ >a {
+ padding: 16px 8px;
+ color: inherit;
+ text-decoration: none;
+ align-content: center;
+ &:hover {
+ background-color: rgb(from var(--color--text) r g b / 10%);
+ }
+ >svg {
+ display: block;
+ }
+ &.active {
+ box-shadow: calc(var(--widget--border-width) * -1) 0 0 var(--widget--border-color),
+ var(--widget--border-width) 0 0 var(--widget--border-color);
+ }
+ }
+ >* {
+ display: block;
+ }
+}
+main {
+ grid-area: main;
+ padding: 8px;
+}
+footer {
+ grid-area: footer;
+ display: grid;
+ grid-template: "about prefs" minmax(0, 1fr)
+ / minmax(0, 1fr) max-content;
+ align-items: center;
+ padding: 16px;
+ border-top: var(--widget--border-width) solid var(--widget--border-color);
+ #about {
+ grid-area: about;
+ }
+ #preferences {
+ grid-area: prefs;
+ }
+}
+.form-group {
+ display: flex;
+ flex-direction: column;
+ margin-block: 8px;
+ label {
+ /* font-size: 80%;
+ font-weight: bold; */
+ align-self: start;
+ }
+ >input, >select, >textarea, >.input {
+ align-self: stretch;
+ width: 100%;
+ display: block;
+ }
+ &.form-actions {
+ flex-direction: row;
+ justify-content: start;
+ align-items: center;
+ gap: 8px;
+ }
+ &.form-additional {
+ display: block;
+ }
+}
+.form-inline {
+ display: inline-flex;
+ margin: 0;
+ padding: 0;
+ align-items: center;
+ gap: 8px;
+ .form-group {
+ margin: 0;
+ flex-direction: row;
+ align-items: center;
+ label {
+ margin-right: 0.25em;
+ }
+ >* {
+ align-self: unset;
+ }
+ }
+}
+
+.main-form {
+ max-inline-size: 480px;
+ margin-inline: auto;
+ margin-top: 32px;
+}
+
+input:is(
+ :not([type]),
+ [type="text"],
+ [type="email"],
+ [type="password"],
+ [type="search"],
+ [type="number"]
+),
+textarea,
+select {
+ appearance: none;
+ border: var(--widget--border-width) solid var(--widget--border-color);
+ background: none;
+ font: inherit;
+ border-radius: var(--border-radius);
+ padding: 6px;
+
+ &:focus-visible {
+ background-color: rgb(from var(--color--primary) r g b / 10%);
+ }
+}
+
+select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-size: 16px 16px;
+ background-repeat: no-repeat;
+ background-position: right 4px center;
+ padding-right: 24px;
+}
+
+button,
+input:is(
+ [type="button"],
+ [type="submit"],
+ [type="reset"]
+),
+.btn {
+ appearance: none;
+ background: none;
+ display: inline-flex;
+ align-items: center;
+ font-family: var(--font-family--sans-serif);
+ font-weight: 800;
+ font-size: var(--font-size--base);
+ gap: 0.25em;
+ text-decoration: none;
+ padding: 6px 16px;
+ border-radius: var(--border-radius);
+ border: var(--widget--border-width) solid var(--widget--border-color);
+ cursor: pointer;
+ color: var(--color--text);
+
+ &:hover {
+ background-color: rgb(from var(--color--text) r g b / 10%);
+ }
+
+ &.btn-link {
+ border-color: transparent;
+ color: var(--color--links);
+
+ text-decoration: underline;
+ font-weight: inherit;
+ }
+
+ &.btn-default {
+ --_bg: var(--color--btn--default-bg);
+ --_fg: var(--color--btn--default-fg);
+ }
+ &.btn-primary {
+ --_bg: var(--color--btn--primary-bg);
+ --_fg: var(--color--btn--primary-fg);
+ }
+ &.btn-info {
+ --_bg: var(--color--btn--info-bg);
+ --_fg: var(--color--btn--info-fg);
+ }
+ &.btn-success {
+ --_bg: var(--color--btn--success-bg);
+ --_fg: var(--color--btn--success-fg);
+ }
+ &.btn-warning {
+ --_bg: var(--color--btn--warning-bg);
+ --_fg: var(--color--btn--warning-fg);
+ }
+ &.btn-danger {
+ --_bg: var(--color--btn--danger-bg);
+ --_fg: var(--color--btn--danger-fg);
+ }
+
+ &:is(
+ .btn-default,
+ .btn-primary,
+ .btn-info,
+ .btn-success,
+ .btn-warning,
+ .btn-danger
+ ) {
+ box-shadow: none;
+ background: var(--_bg);
+ border-color: var(--_bg);
+ color: var(--_fg);
+ &:hover {
+ border-color: hsl(from var(--_bg) h s calc(l + 15));
+ background: hsl(from var(--_bg) h s calc(l + 15));
+ }
+ }
+
+ &.btn-iconic {
+ padding-inline: 6px;
+ }
+}
+
+.icon {
+ width: 16px;
+ height: 16px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ flex-shrink: 0;
+}
+
+.post-list {
+ border-top: var(--widget--border-width) solid var(--widget--border-color);
+ margin-inline: -8px;
+ >._item {
+ display: block;
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ padding: 16px;
+ color: inherit;
+ text-decoration: none;
+ &:hover {
+ background-color: rgb(from var(--color--text) r g b / 10%);
+ }
+ >._heading {
+ font-size: inherit;
+ font-weight: bold;
+ margin: 0;
+ }
+ >._text {
+ margin: 0;
+ }
+ }
+}
+
+.actions {
+ inline-size: max-content;
+ margin-inline-start: auto;
+ margin-bottom: 8px;
+ white-space: nowrap;
+ &:first-child {
+ margin: -8px -8px 0 auto;
+ >.btn {
+ border-top: 0;
+ border-bottom: 0;
+ border-right: 0;
+ }
+ }
+}
+
+.page-actions {
+ display: flex;
+ justify-content: end;
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ margin-inline: -8px;
+ gap: 8px;
+ button,
+ .btn {
+ border-top: 0;
+ border-bottom: 0;
+ }
+ form {
+ display: contents;
+ margin: 0;
+ padding: 0;
+ }
+}
+
+.rt-toolbar,
+.btn-toolbar {
+ display: flex;
+ gap: 8px;
+ button,
+ .btn {
+ border-width: 0;
+ border-right-width: var(--widget--border-width);
+ &:first-child {
+ border-left-width: var(--widget--border-width);
+ }
+ }
+ .rt-group,
+ .btn-group {
+ display: flex;
+ &:first-child > button:first-child {
+ border-left-width: 0;
+ }
+ }
+}
+
+.btn-toolbar .btn-group {
+ border-top: var(--widget--border-width) solid var(--widget--border-color);
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+}
+
+.text-primary { color: var(--color--primary); }
+.text-info { color: var(--color--info); }
+.text-success { color: var(--color--success); }
+.text-warning { color: var(--color--warning); }
+.text-danger { color: var(--color--danger); }
+.text-muted { color: var(--color--muted); }
+.text-left { text-align: left; }
+.text-center { text-align: center; }
+.text-right { text-align: right; }
+
+.richtext-editor {
+ display: flex;
+ flex-direction: column;
+ border: var(--widget--border-width) solid var(--widget--border-color);
+ .rt-toolbar {
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ }
+ textarea {
+ font-family: var(--font-family--monospace);
+ border: none;
+ resize: vertical;
+ max-height: 500px;
+ }
+}
+
+.post-reply .richtext-editor {
+ margin-inline: -8px;
+ border-left: 0;
+ border-right: 0;
+}
+
+.sr-only {
+ display: inline-block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ clip: rect(0 0 0 0);
+ overflow: hidden;
+ opacity: 0;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ background: #000c;
+ transition: opacity 0.2s ease;
+ padding: 16px;
+ &.fade {
+ opacity: 0;
+ pointer-events: none;
+ .modal-dialog {
+ translate: 0% -128px;
+ }
+ }
+}
+.modal-dialog {
+ background: var(--color--background);
+ color: var(--color--text);
+ border: var(--widget--border-width) solid var(--widget--border-color);
+ max-inline-size: 1002px;
+ margin: auto;
+ transition: inherit;
+ transition-property: translate;
+ &.modal-primary .modal-header {
+ color: var(--color--primary);
+ background-color: rgb(from var(--color--primary) r g b / 10%);
+ }
+ &.modal-info .modal-header {
+ color: var(--color--info);
+ background-color: rgb(from var(--color--info) r g b / 10%);
+ }
+ &.modal-success .modal-header {
+ color: var(--color--success);
+ background-color: rgb(from var(--color--success) r g b / 10%);
+ }
+ &.modal-warning .modal-header {
+ color: var(--color--warning);
+ background-color: rgb(from var(--color--warning) r g b / 10%);
+ }
+ &.modal-danger .modal-header {
+ color: var(--color--danger);
+ background-color: rgb(from var(--color--danger) r g b / 10%);
+ }
+}
+.modal-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) max-content;
+ padding: 0 16px;
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ align-items: center;
+ >button,
+ >.btn {
+ border-top: 0;
+ border-bottom: 0;
+ }
+ >.close {
+ margin-right: -16px;
+ }
+}
+.modal-header>.close {
+ grid-column: 2;
+}
+.modal-title {
+ grid-column: 1;
+ grid-row: 1;
+ margin: 0;
+ font-size: inherit;
+ font-weight: 800;
+ &:has(>svg) {
+ display: flex;
+ gap: 0.25em;
+ align-items: center;
+ >svg {
+ display: block;
+ }
+ }
+}
+.modal-footer {
+ display: flex;
+ border-top: var(--widget--border-width) solid var(--widget--border-color);
+ padding: 0 16px;
+ gap: 8px;
+ justify-content: end;
+ >button,
+ >.btn {
+ border-top: 0;
+ border-bottom: 0;
+ }
+}
+.modal-body {
+ padding: 16px;
+}
+
+.edit-post-wrapper {
+ padding: 0;
+ >.richtext-editor {
+ border: 0;
+ }
+}
+
+.pull-right {
+ float: right;
+}
+
+:has(>.pull-right)::after {
+ content: "";
+ display: block;
+ clear: both;
+}
+
+.title-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ >form {
+ display: contents;
+ }
+}
+
+.page-header {
+ padding-block-end: 8px;
+ margin-bottom: 8px;
+ margin-inline: -8px;
+ padding-inline: 8px;
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ >h1 {
+ margin: 0;
+ }
+}
+.page-header:has(~ .post) {
+ margin-bottom: 0;
+}
+
+.post {
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ margin-inline: -8px;
+ z-index: 0;
+ position: relative;
+}
+.post-pfp {
+ border-right: var(--widget--border-width) solid var(--widget--border-color);
+ padding: 8px;
+ position: relative;
+ >a {
+ &, >img {
+ display: block;
+ }
+ }
+}
+.post-title {
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ padding: 8px;
+ position: sticky;
+ top: var(--header-height);
+ background: var(--color--background);
+ font-size: 80%;
+}
+.post-attachments {
+ padding: 8px;
+ border-top: var(--widget--border-width) solid var(--widget--border-color);
+}
+.post-main {
+ padding: 8px;
+}
+.post-body:has(>.post-status) {
+ align-content: center;
+}
+.post-status {
+ padding: 8px;
+ display: flex;
+ gap: 0.25em;
+ height: 100%;
+ align-items: center;
+ &.post-status-primary {
+ color: var(--color--primary);
+ background-color: rgb(from var(--color--primary) r g b / 10%);
+ }
+ &.post-status-info {
+ color: var(--color--info);
+ background-color: rgb(from var(--color--info) r g b / 10%);
+ }
+ &.post-status-success {
+ color: var(--color--success);
+ background-color: rgb(from var(--color--success) r g b / 10%);
+ }
+ &.post-status-warning {
+ color: var(--color--warning);
+ background-color: rgb(from var(--color--warning) r g b / 10%);
+ }
+ &.post-status-danger {
+ color: var(--color--danger);
+ background-color: rgb(from var(--color--danger) r g b / 10%);
+ }
+}
+.post-content {
+ word-break: break-word;
+ hyphens: auto;
+}
+
+.icon-in-text {
+ vertical-align: middle;
+}
+
+.is-you {
+ color: var(--color--primary);
+}
+
+blockquote {
+ padding: 8px;
+ margin-left: 0;
+ border-left: var(--widget--border-width) solid var(--widget--border-color);
+}
+
+#image-attachment-view,
+#video-attachment-view {
+ max-width: 100%;
+ margin-inline: auto;
+ display: block;
+}
+
+.spring-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ >.spring-fill {
+ flex: 1;
+ }
+ >.spring-fit {
+ flex-shrink: 0;
+ }
+}
+
+.spring-fill input {
+ display: block;
+ width: 100%;
+}
+
+.post-anchor {
+ position: absolute;
+ left: 0;
+ top: calc(-1 * var(--header-height));
+ width: 0;
+ height: 0;
+}
+
+.topic-info-box {
+ padding: 16px;
+ text-align: center;
+ margin: 0 -8px calc(-8px - var(--widget--border-width));
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ &.primary {
+ background-color: rgb(from var(--color--primary) r g b / 10%);
+ }
+ &.info {
+ background-color: rgb(from var(--color--info) r g b / 10%);
+ }
+ &.success {
+ background-color: rgb(from var(--color--success) r g b / 10%);
+ }
+ &.warning {
+ background-color: rgb(from var(--color--warning) r g b / 10%);
+ }
+ &.danger {
+ background-color: rgb(from var(--color--danger) r g b / 10%);
+ }
+
+ >h3 {
+ margin: 0 0 8px;
+ }
+}
+
+.alert {
+ padding: 16px;
+ margin-bottom: 16px;
+ border: var(--widget--border-width) solid var(--widget--border-color);
+ &.alert-primary {
+ color: var(--color--primary);
+ background-color: rgb(from var(--color--primary) r g b / 10%);
+ }
+ &.alert-info {
+ color: var(--color--info);
+ background-color: rgb(from var(--color--info) r g b / 10%);
+ }
+ &.alert-success {
+ color: var(--color--success);
+ background-color: rgb(from var(--color--success) r g b / 10%);
+ }
+ &.alert-warning {
+ color: var(--color--warning);
+ background-color: rgb(from var(--color--warning) r g b / 10%);
+ }
+ &.alert-danger {
+ color: var(--color--danger);
+ background-color: rgb(from var(--color--danger) r g b / 10%);
+ }
+ &:is(main>.alert) {
+ padding: 24px;
+ margin: -8px -8px 0;
+ border-left: 0;
+ border-top: 0;
+ border-right: 0;
+ &:is(.page-header:has(~ .post) + .alert) {
+ margin-top: 0;
+ }
+ }
+ >svg {
+ vertical-align: sub;
+ }
+}
+
+.result-alert {
+ margin: 8px -8px 0;
+ >.alert {
+ padding: 24px;
+ margin: 0;
+ border-left: 0;
+ border-right: 0;
+ }
+ &:is(.profile-edit-view .result-alert) {
+ margin-inline: 0;
+ }
+}
+
+.topic-locked {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ gap: 0.25em;
+ color: var(--color--warning);
+}
+
+.profile-header {
+ display: grid;
+ grid-template: "image name" minmax(0, 1fr)
+ "image info" max-content
+ / max-content minmax(0, 1fr);
+ column-gap: 8px;
+ >._image {
+ grid-area: image;
+ }
+ >._name {
+ grid-area: name;
+ align-self: center;
+ }
+ >._info {
+ grid-area: info;
+ align-self: center;
+ }
+}
+
+main:has(>.profile-edit-view) {
+ display: flex;
+ flex-direction: column;
+}
+
+.profile-edit-view {
+ margin: -8px;
+ display: grid;
+ grid-template: "posts edit" minmax(0, 1fr)
+ / minmax(0, 1fr) max-content;
+ flex: 1;
+ h3 {
+ padding-inline: 8px;
+ }
+ >._posts {
+ grid-area: posts;
+ .post-list {
+ margin-inline: 0;
+ }
+ }
+ >._edit {
+ grid-area: edit;
+ border-left: var(--widget--border-width) solid var(--widget--border-color);
+ width: 300px;
+ h3 + form {
+ border-top: var(--widget--border-width) solid var(--widget--border-color);
+ }
+ form {
+ padding-inline: 8px;
+ }
+ form:has(+ h3) {
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ }
+ }
+}
+
+.post-images {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.image-attachment {
+ display: block;
+ margin-right: 8px;
+ text-decoration: none;
+ position: relative;
+ .image-attachment-image {
+ display: block;
+ border: var(--widget--border-width) solid var(--widget--border-color);
+ }
+ .attachment-lock {
+ position: absolute;
+ color: white;
+ right: 6px;
+ top: 6px;
+ text-shadow: 0 1px 3px rgba(0,0,0,.5);
+ }
+ .video-player-icon {
+ position: absolute;
+ color: white;
+ inset: 0;
+ margin: auto;
+ filter: drop-shadow(0 1px 3px rgba(0,0,0,.5));
+ width: 24px;
+ height: 24px;
+ }
+}
+
+@media (max-width: 900px) {
+ footer {
+ display: block;
+ #preferences {
+ margin-top: 8px;
+ }
+ }
+}
+
+@media (max-width: 780px) {
+ main:has(>.profile-edit-view) {
+ display: block;
+ }
+
+ .profile-edit-view {
+ grid-template: "edit" max-content
+ "posts" minmax(0, 1fr)
+ / minmax(0, 1fr);
+ >._posts {
+ margin-bottom: 8px;
+ }
+ >._edit {
+ width: auto;
+ border-bottom: var(--widget--border-width) solid var(--widget--border-color);
+ border-left: 0;
+ }
+ }
+}
+
+@media (max-width: 560px) {
+ .post {
+ grid-template-columns: minmax(0, 1fr);
+ .post-pfp {
+ display: none;
+ }
+ }
+ footer #preferences form {
+ display: block;
+ margin-top: 4px;
+ }
+}
+
+#group0 {
+ display: none !important;
+}
+
+@media (prefers-reduced-motion) {
+ *, *::before, *::after {
+ transition: none !important;
+ }
+}