diff options
Diffstat (limited to 'garden')
| -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 |
10 files changed, 872 insertions, 5 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 |
