From 344d02a7feddefb5c08f88dbe5f3a3f7e7da385f Mon Sep 17 00:00:00 2001 From: Bobby <30593201+luciferreeves@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:25:44 +0530 Subject: feat: add letters feature with detail view and listing - 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. --- garden/src/components/Layout.tsx | 4 +- garden/src/index.css | 3 +- garden/src/pages/letters/detail.tsx | 348 ++++++++++++++++++++++++++++++++++ garden/src/pages/letters/index.tsx | 105 +++++++++++ garden/src/routes.ts | 2 + garden/src/store/stats.ts | 3 +- garden/src/styles/districts.css | 2 +- garden/src/styles/letters.css | 362 ++++++++++++++++++++++++++++++++++++ garden/src/types/letter.ts | 46 +++++ garden/src/types/stats.ts | 2 + scripts/seed.sh | 14 +- shrine/controllers/stats.go | 5 +- shrine/enums/privacy.go | 8 + shrine/messages/letter.go | 1 + shrine/messages/warning.go | 3 +- shrine/models/user.go | 22 ++- shrine/repositories/letter.go | 11 ++ shrine/repositories/user.go | 4 +- shrine/router/council.go | 1 - shrine/services/letter.go | 3 + shrine/services/stats.go | 14 +- shrine/services/warning.go | 2 + shrine/types/user/user.go | 10 +- 23 files changed, 945 insertions(+), 30 deletions(-) create mode 100644 garden/src/pages/letters/detail.tsx create mode 100644 garden/src/pages/letters/index.tsx create mode 100644 garden/src/styles/letters.css create mode 100644 garden/src/types/letter.ts create mode 100644 shrine/enums/privacy.go 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) => ( <>
  • My Domain
  • -
  • Letters
  • +
  • Letters 0}> ({stats.data()!.unread_letters})
  • Account
  • Settings
  • @@ -122,7 +122,7 @@ export default function Layout(props: LayoutProps) {
  • Banned IPs
  • Bazaar
  • -
  • Districts
  • +
  • Districts 0}> ({stats.data()!.pending_districts})
  • Forums
  • Reports
  • Users
  • 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(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(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(`/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(`/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(`/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 ( +
    + Loading...}> + {error()}}> + + {(l) => ( + <> +
    + +

    {l().title}

    +
    + + + + +
    +
    + +
    + + {(p) => ( + + + {p.display_name} + + )} + +
    + +
    + + {(message) => ( +
    + This message was deleted.
    + }> +
    + + + {message.sender!.display_name} + + + {formatDateTime(message.created_at)} + (edited) + +
    + + + }> +
    +