summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--garden/src/components/Layout.tsx4
-rw-r--r--garden/src/index.css3
-rw-r--r--garden/src/pages/letters/detail.tsx348
-rw-r--r--garden/src/pages/letters/index.tsx105
-rw-r--r--garden/src/routes.ts2
-rw-r--r--garden/src/store/stats.ts3
-rw-r--r--garden/src/styles/districts.css2
-rw-r--r--garden/src/styles/letters.css362
-rw-r--r--garden/src/types/letter.ts46
-rw-r--r--garden/src/types/stats.ts2
-rwxr-xr-xscripts/seed.sh14
-rw-r--r--shrine/controllers/stats.go5
-rw-r--r--shrine/enums/privacy.go8
-rw-r--r--shrine/messages/letter.go1
-rw-r--r--shrine/messages/warning.go3
-rw-r--r--shrine/models/user.go22
-rw-r--r--shrine/repositories/letter.go11
-rw-r--r--shrine/repositories/user.go4
-rw-r--r--shrine/router/council.go1
-rw-r--r--shrine/services/letter.go3
-rw-r--r--shrine/services/stats.go14
-rw-r--r--shrine/services/warning.go2
-rw-r--r--shrine/types/user/user.go10
23 files changed, 945 insertions, 30 deletions
diff --git a/garden/src/components/Layout.tsx b/garden/src/components/Layout.tsx
index 23d1b7e..061a480 100644
--- a/garden/src/components/Layout.tsx
+++ b/garden/src/components/Layout.tsx
@@ -83,7 +83,7 @@ export default function Layout(props: LayoutProps) {
{((user) => (
<>
<li><A href={`/p/${user.username}`}>My Domain</A></li>
- <li><A href="/letters">Letters</A></li>
+ <li><A href="/letters">Letters<Show when={(stats.data()?.unread_letters ?? 0) > 0}> ({stats.data()!.unread_letters})</Show></A></li>
<li><A href="/account">Account</A></li>
<li><A href="/account/settings">Settings</A></li>
<li><button type="button" class="sidebar-logout" onClick={() => auth.logout()}>Log Out</button></li>
@@ -122,7 +122,7 @@ export default function Layout(props: LayoutProps) {
<li><A href="/council/bannedips">Banned IPs</A></li>
</Show>
<li><A href="/council/bazaar">Bazaar</A></li>
- <li><A href="/council/districts">Districts</A></li>
+ <li><A href="/council/districts">Districts<Show when={(stats.data()?.pending_districts ?? 0) > 0}> ({stats.data()!.pending_districts})</Show></A></li>
<li><A href="/council/forums">Forums</A></li>
<li><A href="/council/reports">Reports</A></li>
<li><A href="/council/users">Users</A></li>
diff --git a/garden/src/index.css b/garden/src/index.css
index d1b4530..1beda42 100644
--- a/garden/src/index.css
+++ b/garden/src/index.css
@@ -4,4 +4,5 @@
@import "./styles/modal.css";
@import "./styles/editor.css";
@import "./styles/datepicker.css";
-@import "./styles/districts.css"; \ No newline at end of file
+@import "./styles/districts.css";
+@import "./styles/letters.css"; \ No newline at end of file
diff --git a/garden/src/pages/letters/detail.tsx b/garden/src/pages/letters/detail.tsx
new file mode 100644
index 0000000..04ddb04
--- /dev/null
+++ b/garden/src/pages/letters/detail.tsx
@@ -0,0 +1,348 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { useParams, useNavigate } from "@solidjs/router";
+import { api } from "../../api";
+import { auth } from "../../store/auth";
+import { extractError } from "../../utils/api";
+import { formatDateTime } from "../../utils/format";
+import type { LetterDetail, LetterMessage } from "../../types/letter";
+import Modal from "../../components/Modal";
+
+export default function LetterDetailPage() {
+ const params = useParams();
+ const navigate = useNavigate();
+
+ const [letter, setLetter] = createSignal<LetterDetail | null>(null);
+ const [loading, setLoading] = createSignal(true);
+ const [error, setError] = createSignal("");
+
+ const [replyBody, setReplyBody] = createSignal("");
+ const [replyError, setReplyError] = createSignal("");
+ const [replying, setReplying] = createSignal(false);
+
+ const [editingRef, setEditingRef] = createSignal("");
+ const [editBody, setEditBody] = createSignal("");
+ const [editError, setEditError] = createSignal("");
+ const [saving, setSaving] = createSignal(false);
+
+ const [deleteTarget, setDeleteTarget] = createSignal<LetterMessage | null>(null);
+ const [deleting, setDeleting] = createSignal(false);
+
+ const [renaming, setRenaming] = createSignal(false);
+ const [renameTitle, setRenameTitle] = createSignal("");
+ const [renameError, setRenameError] = createSignal("");
+ const [renameSaving, setRenameSaving] = createSignal(false);
+
+ const [leaving, setLeaving] = createSignal(false);
+ const [leaveError, setLeaveError] = createSignal("");
+
+ let messagesEndRef: HTMLDivElement | undefined;
+
+ onMount(() => {
+ if (!auth.token()) {
+ navigate("/login");
+ return;
+ }
+ loadLetter();
+ });
+
+ async function loadLetter() {
+ setLoading(true);
+ const response = await api<LetterDetail>(`/letters/${params.ref}`, {
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ setLetter(response.data);
+ setTimeout(() => messagesEndRef?.scrollIntoView({ behavior: "smooth" }), 100);
+ } else {
+ setError(extractError(response.data));
+ }
+ setLoading(false);
+ }
+
+ async function sendReply() {
+ if (!replyBody().trim()) return;
+ setReplying(true);
+ setReplyError("");
+
+ const response = await api<LetterMessage>(`/letters/${params.ref}/messages`, {
+ method: "POST",
+ token: auth.token(),
+ body: { body: replyBody().trim(), attachment_refs: [] },
+ });
+
+ if (response.ok) {
+ setReplyBody("");
+ loadLetter();
+ } else {
+ setReplyError(extractError(response.data));
+ }
+ setReplying(false);
+ }
+
+ function startEdit(message: LetterMessage) {
+ setEditingRef(message.ref);
+ setEditBody(message.body.replace(/<[^>]*>/g, ""));
+ setEditError("");
+ }
+
+ async function submitEdit() {
+ if (!editBody().trim()) return;
+ setSaving(true);
+ setEditError("");
+
+ const response = await api<LetterMessage>(`/letters/${params.ref}/messages/${editingRef()}`, {
+ method: "PATCH",
+ token: auth.token(),
+ body: { body: editBody().trim() },
+ });
+
+ if (response.ok) {
+ setEditingRef("");
+ loadLetter();
+ } else {
+ setEditError(extractError(response.data));
+ }
+ setSaving(false);
+ }
+
+ async function confirmDelete() {
+ const target = deleteTarget();
+ if (!target) return;
+ setDeleting(true);
+
+ const response = await api(`/letters/${params.ref}/messages/${target.ref}`, {
+ method: "DELETE",
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ setDeleteTarget(null);
+ loadLetter();
+ }
+ setDeleting(false);
+ }
+
+ function openRename() {
+ setRenameTitle(letter()?.title || "");
+ setRenameError("");
+ setRenaming(true);
+ }
+
+ async function submitRename() {
+ setRenameSaving(true);
+ setRenameError("");
+
+ const response = await api(`/letters/${params.ref}`, {
+ method: "PATCH",
+ token: auth.token(),
+ body: { title: renameTitle().trim() },
+ });
+
+ if (response.ok) {
+ setRenaming(false);
+ loadLetter();
+ } else {
+ setRenameError(extractError(response.data));
+ }
+ setRenameSaving(false);
+ }
+
+ async function leaveLetter() {
+ setLeaving(true);
+ setLeaveError("");
+
+ const response = await api(`/letters/${params.ref}/leave`, {
+ method: "POST",
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ navigate("/letters");
+ } else {
+ setLeaveError(extractError(response.data));
+ }
+ setLeaving(false);
+ }
+
+ function isOwnMessage(message: LetterMessage) {
+ return message.sender?.username === auth.user()?.username;
+ }
+
+ function handleReplyKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendReply();
+ }
+ }
+
+ return (
+ <section>
+ <Show when={!loading()} fallback={<div class="letters-empty">Loading...</div>}>
+ <Show when={!error()} fallback={<div class="form-error">{error()}</div>}>
+ <Show when={letter()}>
+ {(l) => (
+ <>
+ <div class="letter-detail-header">
+ <button type="button" class="letter-back" onClick={() => navigate("/letters")}>&larr; Back</button>
+ <h2 class="letter-detail-title">{l().title}</h2>
+ <div class="letter-detail-actions">
+ <Show when={!l().is_system}>
+ <button type="button" class="action-btn" onClick={openRename}>Rename</button>
+ <button type="button" class="action-btn deny" onClick={() => setLeaveError("confirm")}>Leave</button>
+ </Show>
+ </div>
+ </div>
+
+ <div class="letter-participants">
+ <For each={l().participants}>
+ {(p) => (
+ <span class="letter-participant">
+ <img src={p.avatar_url} alt="" class="letter-participant-avatar" />
+ {p.display_name}
+ </span>
+ )}
+ </For>
+ </div>
+
+ <div class="letter-messages">
+ <For each={l().messages}>
+ {(message) => (
+ <div class="letter-message" classList={{ "letter-message-own": isOwnMessage(message), "letter-message-deleted": message.deleted }}>
+ <Show when={!message.deleted} fallback={
+ <div class="letter-message-body letter-message-deleted-text">This message was deleted.</div>
+ }>
+ <div class="letter-message-sender">
+ <Show when={message.sender}>
+ <img src={message.sender!.avatar_url} alt="" class="letter-message-avatar" />
+ <span class="letter-message-name">{message.sender!.display_name}</span>
+ </Show>
+ <span class="letter-message-time">
+ {formatDateTime(message.created_at)}
+ <Show when={message.edited_at}> (edited)</Show>
+ </span>
+ </div>
+
+ <Show when={editingRef() === message.ref} fallback={
+ <div class="letter-message-body" innerHTML={message.body} />
+ }>
+ <div class="letter-message-edit">
+ <textarea
+ value={editBody()}
+ onInput={(e) => setEditBody(e.currentTarget.value)}
+ rows={3}
+ />
+ <Show when={editError()}>
+ <div class="form-error">{editError()}</div>
+ </Show>
+ <div class="letter-message-edit-actions">
+ <button type="button" class="action-btn approve" onClick={submitEdit} disabled={saving()}>Save</button>
+ <button type="button" class="action-btn" onClick={() => setEditingRef("")}>Cancel</button>
+ </div>
+ </div>
+ </Show>
+
+ <Show when={message.attachments.length > 0}>
+ <div class="letter-attachments">
+ <For each={message.attachments}>
+ {(att) => (
+ <a href={att.url} target="_blank" rel="noopener noreferrer" class="letter-attachment">
+ <Show when={att.category === "image"} fallback={
+ <span class="letter-attachment-file">{att.file_name}</span>
+ }>
+ <img src={att.url} alt={att.file_name} class="letter-attachment-image" />
+ </Show>
+ </a>
+ )}
+ </For>
+ </div>
+ </Show>
+
+ <Show when={isOwnMessage(message) && editingRef() !== message.ref}>
+ <div class="letter-message-actions">
+ <button type="button" class="letter-msg-action" onClick={() => startEdit(message)}>Edit</button>
+ <button type="button" class="letter-msg-action letter-msg-action-delete" onClick={() => setDeleteTarget(message)}>Delete</button>
+ </div>
+ </Show>
+ </Show>
+ </div>
+ )}
+ </For>
+ <div ref={messagesEndRef} />
+ </div>
+
+ <Show when={!l().is_system}>
+ <div class="letter-reply">
+ <textarea
+ placeholder="Write a reply..."
+ value={replyBody()}
+ onInput={(e) => setReplyBody(e.currentTarget.value)}
+ onKeyDown={handleReplyKeyDown}
+ rows={3}
+ />
+ <Show when={replyError()}>
+ <div class="form-error">{replyError()}</div>
+ </Show>
+ <button type="button" class="form-button" onClick={sendReply} disabled={replying()}>
+ {replying() ? "Sending..." : "Send"}
+ </button>
+ </div>
+ </Show>
+ </>
+ )}
+ </Show>
+ </Show>
+ </Show>
+
+ <Show when={deleteTarget()}>
+ <Modal title="Delete Message" onClose={() => setDeleteTarget(null)}>
+ <p>Are you sure you want to delete this message?</p>
+ <div class="modal-actions">
+ <button type="button" class="form-button" onClick={confirmDelete} disabled={deleting()}>
+ {deleting() ? "Deleting..." : "Delete"}
+ </button>
+ <button type="button" class="form-button secondary" onClick={() => setDeleteTarget(null)}>Cancel</button>
+ </div>
+ </Modal>
+ </Show>
+
+ <Show when={renaming()}>
+ <Modal title="Rename Conversation" onClose={() => setRenaming(false)}>
+ <div class="form-field">
+ <label>Title</label>
+ <input
+ type="text"
+ value={renameTitle()}
+ onInput={(e) => setRenameTitle(e.currentTarget.value)}
+ maxLength={200}
+ />
+ </div>
+ <Show when={renameError()}>
+ <div class="form-error">{renameError()}</div>
+ </Show>
+ <div class="modal-actions">
+ <button type="button" class="form-button" onClick={submitRename} disabled={renameSaving()}>
+ {renameSaving() ? "Saving..." : "Save"}
+ </button>
+ <button type="button" class="form-button secondary" onClick={() => setRenaming(false)}>Cancel</button>
+ </div>
+ </Modal>
+ </Show>
+
+ <Show when={leaveError() === "confirm"}>
+ <Modal title="Leave Conversation" onClose={() => setLeaveError("")}>
+ <p>Are you sure you want to leave this conversation? You will no longer receive messages.</p>
+ <Show when={leaveError() && leaveError() !== "confirm"}>
+ <div class="form-error">{leaveError()}</div>
+ </Show>
+ <div class="modal-actions">
+ <button type="button" class="form-button" onClick={leaveLetter} disabled={leaving()}>
+ {leaving() ? "Leaving..." : "Leave"}
+ </button>
+ <button type="button" class="form-button secondary" onClick={() => setLeaveError("")}>Cancel</button>
+ </div>
+ </Modal>
+ </Show>
+ </section>
+ );
+} \ No newline at end of file
diff --git a/garden/src/pages/letters/index.tsx b/garden/src/pages/letters/index.tsx
new file mode 100644
index 0000000..2615a1d
--- /dev/null
+++ b/garden/src/pages/letters/index.tsx
@@ -0,0 +1,105 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { A, useNavigate, useSearchParams } from "@solidjs/router";
+import { api } from "../../api";
+import { auth } from "../../store/auth";
+import { formatDateTime } from "../../utils/format";
+import type { Letter } from "../../types/letter";
+import type { PaginatedResponse } from "../../types/admin";
+import Pagination from "../../components/Pagination";
+
+export default function Letters() {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const [letters, setLetters] = createSignal<Letter[]>([]);
+ const [total, setTotal] = createSignal(0);
+ const [page, setPage] = createSignal(1);
+ const [totalPages, setTotalPages] = createSignal(0);
+ const [loading, setLoading] = createSignal(true);
+
+ onMount(() => {
+ if (!auth.token()) {
+ navigate("/login");
+ return;
+ }
+ const p = parseInt(searchParams.page as string) || 1;
+ loadLetters(p);
+ });
+
+ async function loadLetters(pageNumber = 1) {
+ setLoading(true);
+ const response = await api<PaginatedResponse<Letter>>(`/letters?page=${pageNumber}&per_page=20`, {
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ setLetters(response.data.items);
+ setTotal(response.data.total);
+ setPage(response.data.page);
+ setTotalPages(response.data.total_pages);
+ }
+ setLoading(false);
+ }
+
+ function goToPage(p: number) {
+ setSearchParams({ page: String(p) });
+ loadLetters(p);
+ }
+
+ function previewBody(message?: Letter["last_message"]) {
+ if (!message) return "No messages yet";
+ if (message.deleted) return "Message deleted";
+ const text = message.body.replace(/<[^>]*>/g, "");
+ return text.length > 80 ? text.slice(0, 80) + "..." : text;
+ }
+
+ return (
+ <section>
+ <h2 class="page-title">Letters</h2>
+
+ <div class="letters-list">
+ <Show when={!loading()} fallback={
+ <div class="letters-empty">Loading...</div>
+ }>
+ <Show when={letters().length} fallback={
+ <div class="letters-empty">No letters yet.</div>
+ }>
+ <For each={letters()}>
+ {(letter) => (
+ <A href={`/letters/${letter.ref}`} class="letter-item" classList={{ "letter-unread": letter.unread }}>
+ <div class="letter-avatars">
+ <For each={letter.participants.slice(0, 3)}>
+ {(p) => (
+ <img src={p.avatar_url} alt="" class="letter-avatar" />
+ )}
+ </For>
+ <Show when={letter.participants.length > 3}>
+ <span class="letter-avatar-more">+{letter.participants.length - 3}</span>
+ </Show>
+ </div>
+ <div class="letter-content">
+ <div class="letter-top">
+ <span class="letter-title">
+ <Show when={letter.is_system}>
+ <span class="letter-system-badge">System</span>
+ </Show>
+ {letter.title}
+ </span>
+ <span class="letter-time">{formatDateTime(letter.updated_at)}</span>
+ </div>
+ <div class="letter-preview">{previewBody(letter.last_message)}</div>
+ </div>
+ <Show when={letter.unread}>
+ <span class="letter-unread-dot" />
+ </Show>
+ </A>
+ )}
+ </For>
+ </Show>
+ </Show>
+ </div>
+
+ <Pagination page={page()} totalPages={totalPages()} total={total()} label="letters" onPage={goToPage} />
+ </section>
+ );
+} \ No newline at end of file
diff --git a/garden/src/routes.ts b/garden/src/routes.ts
index 0ad9d5c..2a2b6c3 100644
--- a/garden/src/routes.ts
+++ b/garden/src/routes.ts
@@ -18,5 +18,7 @@ export const routes: RouteDefinition[] = [
{ path: "/districts", component: lazy(() => import("./pages/districts/index")) },
{ path: "/districts/submit", component: lazy(() => import("./pages/districts/submit")) },
{ path: "/districts/:slug", component: lazy(() => import("./pages/districts/district")) },
+ { path: "/letters", component: lazy(() => import("./pages/letters/index")) },
+ { path: "/letters/:ref", component: lazy(() => import("./pages/letters/detail")) },
{ path: "**", component: lazy(() => import("./errors/404")) },
];
diff --git a/garden/src/store/stats.ts b/garden/src/store/stats.ts
index e13164f..7082319 100644
--- a/garden/src/store/stats.ts
+++ b/garden/src/store/stats.ts
@@ -5,7 +5,8 @@ import type { Stats } from "../types/stats";
const [data, setData] = createSignal<Stats | null>(null);
async function load() {
- const response = await api<Stats>("/stats/");
+ const token = localStorage.getItem("token");
+ const response = await api<Stats>("/stats/", { token });
if (response.ok) {
setData(response.data);
}
diff --git a/garden/src/styles/districts.css b/garden/src/styles/districts.css
index fae7f2e..ae65e47 100644
--- a/garden/src/styles/districts.css
+++ b/garden/src/styles/districts.css
@@ -341,7 +341,7 @@
.district-req-grid .council-grid-header,
.district-req-grid .council-grid-row {
- grid-template-columns: 3fr 2fr 2fr 1.5fr 2fr;
+ grid-template-columns: 2fr 1.5fr 1.5fr 1fr 3fr;
}
.district-site-grid .council-grid-header,
diff --git a/garden/src/styles/letters.css b/garden/src/styles/letters.css
new file mode 100644
index 0000000..4fa8a1b
--- /dev/null
+++ b/garden/src/styles/letters.css
@@ -0,0 +1,362 @@
+.letters-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.letters-header .page-title {
+ margin: 0;
+}
+
+.letters-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.letters-empty {
+ text-align: center;
+ color: var(--color-text-muted);
+ font-style: italic;
+ padding: 40px 8px;
+}
+
+.letter-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--color-border);
+ text-decoration: none;
+ color: var(--color-text);
+ transition: background 0.15s;
+}
+
+.letter-item:hover {
+ background: var(--color-overlay-light);
+}
+
+.letter-unread {
+ background: var(--color-overlay-lighter);
+}
+
+.letter-avatars {
+ display: flex;
+ flex-shrink: 0;
+}
+
+.letter-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 2px solid var(--color-panel);
+ object-fit: cover;
+}
+
+.letter-avatar:not(:first-child) {
+ margin-left: -10px;
+}
+
+.letter-avatar-more {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--color-panel-header);
+ border: 2px solid var(--color-panel);
+ margin-left: -10px;
+ font-size: 11px;
+ color: var(--color-text-muted);
+}
+
+.letter-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.letter-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.letter-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--color-text-bright);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.letter-system-badge {
+ display: inline-block;
+ background: var(--color-purple);
+ color: var(--color-white);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 1px 5px;
+ margin-right: 6px;
+ vertical-align: middle;
+}
+
+.letter-time {
+ font-size: 11px;
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.letter-preview {
+ font-size: 12px;
+ color: var(--color-text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-top: 2px;
+}
+
+.letter-unread-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color-pink);
+ flex-shrink: 0;
+}
+
+.letter-detail-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.letter-back {
+ background: none;
+ border: none;
+ color: var(--color-link);
+ cursor: pointer;
+ font-family: var(--font-body);
+ font-size: 13px;
+ padding: 0;
+}
+
+.letter-back:hover {
+ color: var(--color-link-hover);
+}
+
+.letter-detail-title {
+ flex: 1;
+ font-size: 18px;
+ margin: 0;
+ color: var(--color-text-bright);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.letter-detail-actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.letter-participants {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px 0;
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.letter-participant {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+
+.letter-participant-avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.letter-messages {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 16px;
+}
+
+.letter-message {
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.letter-message-own {
+ background: var(--color-overlay-lighter);
+}
+
+.letter-message-deleted {
+ opacity: 0.5;
+}
+
+.letter-message-sender {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+
+.letter-message-avatar {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.letter-message-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--color-text-bright);
+}
+
+.letter-message-time {
+ font-size: 11px;
+ color: var(--color-text-muted);
+ margin-left: auto;
+}
+
+.letter-message-body {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--color-text);
+ word-break: break-word;
+}
+
+.letter-message-body p {
+ margin: 0 0 4px;
+}
+
+.letter-message-deleted-text {
+ font-style: italic;
+ color: var(--color-text-muted);
+}
+
+.letter-message-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.letter-msg-action {
+ background: none;
+ border: none;
+ font-family: var(--font-body);
+ font-size: 11px;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ padding: 0;
+}
+
+.letter-msg-action:hover {
+ color: var(--color-text);
+}
+
+.letter-msg-action-delete:hover {
+ color: var(--color-red);
+}
+
+.letter-message-edit {
+ margin-top: 6px;
+}
+
+.letter-message-edit textarea {
+ width: 100%;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: 13px;
+ padding: 6px 8px;
+ resize: vertical;
+ outline: none;
+ box-sizing: border-box;
+}
+
+.letter-message-edit textarea:focus {
+ border-color: var(--color-purple);
+}
+
+.letter-message-edit-actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.letter-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.letter-attachment {
+ text-decoration: none;
+}
+
+.letter-attachment-image {
+ max-width: 200px;
+ max-height: 150px;
+ border: 1px solid var(--color-border);
+ object-fit: cover;
+}
+
+.letter-attachment-file {
+ display: inline-block;
+ padding: 4px 8px;
+ background: var(--color-panel-header);
+ border: 1px solid var(--color-border);
+ font-size: 12px;
+ color: var(--color-link);
+}
+
+.letter-attachment-file:hover {
+ color: var(--color-link-hover);
+}
+
+.letter-reply {
+ border-top: 1px solid var(--color-border);
+ padding-top: 12px;
+}
+
+.letter-reply textarea {
+ width: 100%;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: 13px;
+ padding: 8px;
+ resize: vertical;
+ outline: none;
+ box-sizing: border-box;
+ margin-bottom: 8px;
+}
+
+.letter-reply textarea:focus {
+ border-color: var(--color-purple);
+}
+
+.letter-reply .form-button {
+ margin-top: 0;
+} \ No newline at end of file
diff --git a/garden/src/types/letter.ts b/garden/src/types/letter.ts
new file mode 100644
index 0000000..f7bd9dc
--- /dev/null
+++ b/garden/src/types/letter.ts
@@ -0,0 +1,46 @@
+export interface LetterParticipant {
+ username: string;
+ display_name: string;
+ avatar_url: string;
+ role: string;
+}
+
+export interface LetterAttachment {
+ ref: string;
+ file_name: string;
+ url: string;
+ file_size: number;
+ content_type: string;
+ category: string;
+}
+
+export interface LetterMessage {
+ ref: string;
+ sender: LetterParticipant | null;
+ body: string;
+ attachments: LetterAttachment[];
+ edited_at: string | null;
+ created_at: string;
+ deleted: boolean;
+}
+
+export interface Letter {
+ ref: string;
+ title: string;
+ is_system: boolean;
+ system_ref?: string;
+ participants: LetterParticipant[];
+ last_message?: LetterMessage;
+ unread: boolean;
+ updated_at: string;
+}
+
+export interface LetterDetail {
+ ref: string;
+ title: string;
+ is_system: boolean;
+ system_ref?: string;
+ participants: LetterParticipant[];
+ messages: LetterMessage[];
+ created_at: string;
+} \ No newline at end of file
diff --git a/garden/src/types/stats.ts b/garden/src/types/stats.ts
index 1d9364e..254f0b2 100644
--- a/garden/src/types/stats.ts
+++ b/garden/src/types/stats.ts
@@ -7,6 +7,8 @@ export interface CitizenSummary {
export interface Stats {
citizens: number;
online: number;
+ unread_letters: number;
+ pending_districts: number;
newest_citizens: CitizenSummary[];
online_citizens: CitizenSummary[];
} \ No newline at end of file
diff --git a/scripts/seed.sh b/scripts/seed.sh
index ba580c0..eb49a5f 100755
--- a/scripts/seed.sh
+++ b/scripts/seed.sh
@@ -138,8 +138,8 @@ ONE_MONTH_AGO=$(date -v-1m +%s 2>/dev/null || date -d "1 month ago" +%s)
ONE_MONTH_AHEAD=$(date -v+1m +%s 2>/dev/null || date -d "1 month" +%s)
FOUR_MONTHS_AGO=$(date -v-4m +%s 2>/dev/null || date -d "4 months ago" +%s)
-OWNER_DATE=$(date -r $FOUR_MONTHS_AGO +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -d "@$FOUR_MONTHS_AGO" +"%Y-%m-%dT%H:%M:%SZ")
-OWNER_SEEN=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+OWNER_DATE=$(date -r $FOUR_MONTHS_AGO +"%Y-%m-%d %H:%M:%S+00:00" 2>/dev/null || date -d "@$FOUR_MONTHS_AGO" +"%Y-%m-%d %H:%M:%S+00:00")
+OWNER_SEEN=$(date -u +"%Y-%m-%d %H:%M:%S+00:00")
OWNER_BIO='<p class="editor-paragraph"><i><em class="editor-italic" style="white-space: pre-wrap;">A really awesome cool slick ninja dinosaur thingy</em></i></p>'
OWNER_SIG='<p class="editor-paragraph"><span style="white-space: pre-wrap;">Love and Ciao</span></p>'
@@ -155,7 +155,7 @@ echo "BEGIN TRANSACTION;" > "$SQL_FILE"
OWNER_BIO_ESC=$(escape_sql "$OWNER_BIO")
OWNER_SIG_ESC=$(escape_sql "$OWNER_SIG")
-printf "INSERT OR IGNORE INTO users (username, email, password_hash, display_name, role, email_verified, jade, honor, pronouns, location, bio, signature, birthday, last_seen_at, ip, created_at, updated_at) VALUES ('master', '[email protected]', '%s', 'Master', 'owner', 1, 1000, 500, 'sol/solis', 'The Cloud', '%s', '%s', '1904-03-15T00:00:00Z', '%s', '127.0.0.1', '%s', '%s');\n" \
+printf "INSERT OR IGNORE INTO users (username, email, password_hash, display_name, role, email_verified, jade, honor, pronouns, location, bio, signature, birthday, last_seen_at, ip, created_at, updated_at) VALUES ('master', '[email protected]', '%s', 'Master', 'owner', 1, 1000, 500, 'sol/solis', 'The Cloud', '%s', '%s', '1904-03-15 00:00:00+00:00', '%s', '127.0.0.1', '%s', '%s');\n" \
"$HASH" "$OWNER_BIO_ESC" "$OWNER_SIG_ESC" "$OWNER_SEEN" "$OWNER_DATE" "$OWNER_DATE" >> "$SQL_FILE"
echo "Generating $CITIZEN_COUNT citizens..."
@@ -187,16 +187,16 @@ for ((i=0; i<CITIZEN_COUNT; i++)); do
LOC=${LOCATIONS[$(( RANDOM % ${#LOCATIONS[@]} ))]}
JOIN_EPOCH=$(random_date_between $THREE_MONTHS_AGO $NOW_EPOCH)
- JOIN_DATE=$(date -r $JOIN_EPOCH +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -d "@$JOIN_EPOCH" +"%Y-%m-%dT%H:%M:%SZ")
+ JOIN_DATE=$(date -r $JOIN_EPOCH +"%Y-%m-%d %H:%M:%S+00:00" 2>/dev/null || date -d "@$JOIN_EPOCH" +"%Y-%m-%d %H:%M:%S+00:00")
- SEEN_OFFSET=$(( RANDOM % (7 * 24 * 3600) ))
+ SEEN_OFFSET=$(( RANDOM % (90 * 24 * 3600) ))
SEEN_EPOCH=$(( NOW_EPOCH - SEEN_OFFSET ))
- LAST_SEEN=$(date -r $SEEN_EPOCH +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -d "@$SEEN_EPOCH" +"%Y-%m-%dT%H:%M:%SZ")
+ LAST_SEEN=$(date -r $SEEN_EPOCH +"%Y-%m-%d %H:%M:%S+00:00" 2>/dev/null || date -d "@$SEEN_EPOCH" +"%Y-%m-%d %H:%M:%S+00:00")
BDAY_EPOCH=$(random_date_between $ONE_MONTH_AGO $ONE_MONTH_AHEAD)
BDAY_MONTH=$(date -r $BDAY_EPOCH +"%m" 2>/dev/null || date -d "@$BDAY_EPOCH" +"%m")
BDAY_DAY=$(date -r $BDAY_EPOCH +"%d" 2>/dev/null || date -d "@$BDAY_EPOCH" +"%d")
- BIRTHDAY="1904-${BDAY_MONTH}-${BDAY_DAY}T00:00:00Z"
+ BIRTHDAY="1904-${BDAY_MONTH}-${BDAY_DAY} 00:00:00+00:00"
JADE=$(( RANDOM % 500 ))
HONOR=$(( RANDOM % 200 ))
diff --git a/shrine/controllers/stats.go b/shrine/controllers/stats.go
index bf31c2e..cc2bf93 100644
--- a/shrine/controllers/stats.go
+++ b/shrine/controllers/stats.go
@@ -2,11 +2,14 @@ package controllers
import (
"shrine/services"
+ "shrine/utils/auth"
"shrine/utils/shortcuts"
"github.com/gofiber/fiber/v2"
)
func StatsController(context *fiber.Ctx) error {
- return shortcuts.Success(context, services.GetStats())
+ auth.IsAuthenticated(context)
+ citizen := auth.GetUser(context)
+ return shortcuts.Success(context, services.GetStats(citizen))
} \ No newline at end of file
diff --git a/shrine/enums/privacy.go b/shrine/enums/privacy.go
new file mode 100644
index 0000000..99739f3
--- /dev/null
+++ b/shrine/enums/privacy.go
@@ -0,0 +1,8 @@
+package enums
+
+type LetterPrivacy string
+
+const (
+ LetterPrivacyPublic LetterPrivacy = "public"
+ LetterPrivacyFriends LetterPrivacy = "friends"
+) \ No newline at end of file
diff --git a/shrine/messages/letter.go b/shrine/messages/letter.go
index 49d01f9..181f00d 100644
--- a/shrine/messages/letter.go
+++ b/shrine/messages/letter.go
@@ -29,6 +29,7 @@ const (
FailedUploadFile = "Failed to upload file."
LetterRenamed = "Letter renamed."
LeftConversation = "You have left the conversation."
+ RecipientNotAcceptingLetters = "User '%s' is not accepting letters."
RecipientNotFound = "User '%s' not found."
ParticipantRemoved = "%s has been removed."
FileExceedsMaxSize = "File exceeds the maximum size of %d MB."
diff --git a/shrine/messages/warning.go b/shrine/messages/warning.go
index d29a34b..748844f 100644
--- a/shrine/messages/warning.go
+++ b/shrine/messages/warning.go
@@ -8,6 +8,7 @@ const (
WarningMessageRequired = "Warning message is required."
WarningNotFound = "Warning not found."
WarningAlreadyInactive = "Warning is already inactive."
- FailedCreateWarning = "Failed to create warning."
+ FailedCreateWarning = "Failed to create warning."
+ FailedCreateWarningDetailed = "Failed to create warning for %s: %v."
FailedDeactivateWarn = "Failed to deactivate warning."
) \ No newline at end of file
diff --git a/shrine/models/user.go b/shrine/models/user.go
index 503c61a..8846aa4 100644
--- a/shrine/models/user.go
+++ b/shrine/models/user.go
@@ -45,6 +45,7 @@ type User struct {
DisabledBy *uint `gorm:"index"`
DisabledUntil *time.Time
WarningCount uint `gorm:"not null;default:0"`
+ LetterPrivacy enums.LetterPrivacy `gorm:"size:20;not null;default:public"`
LastSeenAt *time.Time
IP string `gorm:"size:45"`
}
@@ -194,20 +195,27 @@ func (self *User) BeforeCreate(tx *gorm.DB) error {
}
func (self *User) BeforeUpdate(tx *gorm.DB) error {
- if !validators.IsValidEmail(self.Email) {
- return errors.New(messages.InvalidEmail)
+ if self.Email == "" && self.DisplayName == "" {
+ return nil
}
- if len(strings.TrimSpace(self.DisplayName)) < 1 || len(strings.TrimSpace(self.DisplayName)) > 50 {
- return errors.New(messages.InvalidDisplayName)
+ if self.Email != "" {
+ if !validators.IsValidEmail(self.Email) {
+ return errors.New(messages.InvalidEmail)
+ }
+ self.Email = strings.ToLower(strings.TrimSpace(self.Email))
+ }
+
+ if self.DisplayName != "" {
+ if len(strings.TrimSpace(self.DisplayName)) < 1 || len(strings.TrimSpace(self.DisplayName)) > 50 {
+ return errors.New(messages.InvalidDisplayName)
+ }
+ self.DisplayName = strings.TrimSpace(self.DisplayName)
}
if self.Jade > validators.MaxJade {
return errors.New(messages.JadeExceedsMax)
}
- self.Email = strings.ToLower(strings.TrimSpace(self.Email))
- self.DisplayName = strings.TrimSpace(self.DisplayName)
-
return nil
} \ No newline at end of file
diff --git a/shrine/repositories/letter.go b/shrine/repositories/letter.go
index b9c81aa..a52f5bc 100644
--- a/shrine/repositories/letter.go
+++ b/shrine/repositories/letter.go
@@ -118,6 +118,17 @@ func GetLetterMessages(letterID uint, p meta.Pagination) ([]models.LetterMessage
return messages, total
}
+func CountUnreadLetters(userID uint) int64 {
+ var count int64
+ database.DB.Model(&models.LetterParticipant{}).
+ Joins("JOIN letters ON letters.id = letter_participants.letter_id").
+ Joins("JOIN letter_messages ON letter_messages.letter_id = letters.id AND letter_messages.deleted_at IS NULL").
+ Where("letter_participants.user_id = ? AND (letter_participants.last_read_at IS NULL OR letter_messages.created_at > letter_participants.last_read_at)", userID).
+ Distinct("letter_participants.letter_id").
+ Count(&count)
+ return count
+}
+
func ListLettersForUser(userID uint, p meta.Pagination) ([]models.Letter, int64) {
var letters []models.Letter
var total int64
diff --git a/shrine/repositories/user.go b/shrine/repositories/user.go
index 73973da..39b75b5 100644
--- a/shrine/repositories/user.go
+++ b/shrine/repositories/user.go
@@ -49,13 +49,13 @@ func UpdateLastSeen(user *models.User) {
func CountCitizens() int64 {
var count int64
- database.DB.Model(&models.User{}).Where("email_verified = ?", true).Count(&count)
+ database.DB.Model(&models.User{}).Where("email_verified = ? AND account_banned = ? AND account_disabled = ?", true, false, false).Count(&count)
return count
}
func CountOnline() int64 {
var count int64
- database.DB.Model(&models.User{}).Where("last_seen_at > ?", time.Now().Add(-5*time.Minute)).Count(&count)
+ database.DB.Model(&models.User{}).Where("last_seen_at > ? AND account_banned = ? AND account_disabled = ?", time.Now().Add(-5*time.Minute), false, false).Count(&count)
return count
}
diff --git a/shrine/router/council.go b/shrine/router/council.go
index 9603b53..205cd70 100644
--- a/shrine/router/council.go
+++ b/shrine/router/council.go
@@ -44,5 +44,4 @@ func init() {
urls.Path(enums.POST, "/districts/sites/:ref/review", auth.RequireStaff(controllers.ReviewSiteController), "districtreview")
urls.Path(enums.GET, "/districts/sites", auth.RequireStaff(controllers.ListAdminSitesController), "districtsites")
urls.Path(enums.PATCH, "/districts/sites/:ref", auth.RequireStaff(controllers.EditSiteController), "districtedit")
- urls.Path(enums.GET, "/districts/pending", auth.RequireStaff(controllers.CountPendingSitesController), "districtpending")
} \ No newline at end of file
diff --git a/shrine/services/letter.go b/shrine/services/letter.go
index 20a4f93..c7ea741 100644
--- a/shrine/services/letter.go
+++ b/shrine/services/letter.go
@@ -75,6 +75,9 @@ func CreateLetter(userID uint, request letter.CreateRequest) (*common.MessageRes
if recipient.ID == userID {
continue
}
+ if recipient.LetterPrivacy == enums.LetterPrivacyFriends {
+ return nil, fail(enums.BadRequest, fmt.Sprintf(messages.RecipientNotAcceptingLetters, username))
+ }
recipientIDs = append(recipientIDs, recipient.ID)
}
diff --git a/shrine/services/stats.go b/shrine/services/stats.go
index 2e31c02..aa2eaab 100644
--- a/shrine/services/stats.go
+++ b/shrine/services/stats.go
@@ -1,15 +1,25 @@
package services
import (
+ "shrine/models"
"shrine/repositories"
"shrine/types/user"
)
-func GetStats() user.StatsResponse {
- return user.StatsResponse{
+func GetStats(citizen *models.User) user.StatsResponse {
+ response := user.StatsResponse{
Citizens: repositories.CountCitizens(),
Online: repositories.CountOnline(),
NewestCitizens: buildCitizenSummaries(repositories.NewestCitizens(5)),
OnlineCitizens: buildCitizenSummaries(repositories.OnlineCitizens(10)),
}
+
+ if citizen != nil {
+ response.UnreadLetters = repositories.CountUnreadLetters(citizen.ID)
+ if citizen.IsStaff() {
+ response.PendingDistricts = repositories.CountPendingDistrictSites()
+ }
+ }
+
+ return response
} \ No newline at end of file
diff --git a/shrine/services/warning.go b/shrine/services/warning.go
index 0b27591..553c7fb 100644
--- a/shrine/services/warning.go
+++ b/shrine/services/warning.go
@@ -10,6 +10,7 @@ import (
"shrine/types/hypertext"
"shrine/types/warning"
"shrine/utils/auth"
+ "shrine/utils/logger"
"shrine/utils/meta"
"shrine/utils/sanitize"
"strings"
@@ -32,6 +33,7 @@ func WarnUser(admin *models.User, target *models.User, request warning.WarnReque
record, err := repositories.CreateWarning(admin.ID, target.ID, title, sanitizedMessage)
if err != nil {
+ logger.Errorf("Warnings", messages.FailedCreateWarningDetailed, target.Username, err)
return nil, fail(enums.Internal, messages.FailedCreateWarning)
}
diff --git a/shrine/types/user/user.go b/shrine/types/user/user.go
index f28f2f3..7e33d8c 100644
--- a/shrine/types/user/user.go
+++ b/shrine/types/user/user.go
@@ -41,8 +41,10 @@ type AdminUserResponse struct {
}
type StatsResponse struct {
- Citizens int64 `json:"citizens"`
- Online int64 `json:"online"`
- NewestCitizens []CitizenSummaryResponse `json:"newest_citizens"`
- OnlineCitizens []CitizenSummaryResponse `json:"online_citizens"`
+ Citizens int64 `json:"citizens"`
+ Online int64 `json:"online"`
+ UnreadLetters int64 `json:"unread_letters"`
+ PendingDistricts int64 `json:"pending_districts"`
+ NewestCitizens []CitizenSummaryResponse `json:"newest_citizens"`
+ OnlineCitizens []CitizenSummaryResponse `json:"online_citizens"`
} \ No newline at end of file