diff options
| author | Bobby <[email protected]> | 2026-03-13 18:25:44 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-13 18:25:44 +0530 |
| commit | 344d02a7feddefb5c08f88dbe5f3a3f7e7da385f (patch) | |
| tree | 94deed23d82d7f868721cc00b5550f5c27e8b8f7 | |
| parent | 9f808807a557fc10a38a44cb52be6bfcdfda68b2 (diff) | |
| download | pagoda-344d02a7feddefb5c08f88dbe5f3a3f7e7da385f.tar.xz pagoda-344d02a7feddefb5c08f88dbe5f3a3f7e7da385f.zip | |
- Introduced new routes for letters and their details.
- Created pages for displaying letter details and listing letters.
- Added new types for letters, including participants, messages, and attachments.
- Implemented API calls for fetching letters and managing messages (reply, edit, delete).
- Enhanced stats to include unread letters and pending districts for staff users.
- Updated styles for letters and their components.
- Added privacy settings for letters (public and friends).
- Modified user model to include letter privacy settings.
- Improved error handling and user feedback in the UI.
| -rw-r--r-- | garden/src/components/Layout.tsx | 4 | ||||
| -rw-r--r-- | garden/src/index.css | 3 | ||||
| -rw-r--r-- | garden/src/pages/letters/detail.tsx | 348 | ||||
| -rw-r--r-- | garden/src/pages/letters/index.tsx | 105 | ||||
| -rw-r--r-- | garden/src/routes.ts | 2 | ||||
| -rw-r--r-- | garden/src/store/stats.ts | 3 | ||||
| -rw-r--r-- | garden/src/styles/districts.css | 2 | ||||
| -rw-r--r-- | garden/src/styles/letters.css | 362 | ||||
| -rw-r--r-- | garden/src/types/letter.ts | 46 | ||||
| -rw-r--r-- | garden/src/types/stats.ts | 2 | ||||
| -rwxr-xr-x | scripts/seed.sh | 14 | ||||
| -rw-r--r-- | shrine/controllers/stats.go | 5 | ||||
| -rw-r--r-- | shrine/enums/privacy.go | 8 | ||||
| -rw-r--r-- | shrine/messages/letter.go | 1 | ||||
| -rw-r--r-- | shrine/messages/warning.go | 3 | ||||
| -rw-r--r-- | shrine/models/user.go | 22 | ||||
| -rw-r--r-- | shrine/repositories/letter.go | 11 | ||||
| -rw-r--r-- | shrine/repositories/user.go | 4 | ||||
| -rw-r--r-- | shrine/router/council.go | 1 | ||||
| -rw-r--r-- | shrine/services/letter.go | 3 | ||||
| -rw-r--r-- | shrine/services/stats.go | 14 | ||||
| -rw-r--r-- | shrine/services/warning.go | 2 | ||||
| -rw-r--r-- | shrine/types/user/user.go | 10 |
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")}>← 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 |
