diff --git a/packages/api/src/routes/posts.ts b/packages/api/src/routes/posts.ts
index c067185..5286eeb 100644
--- a/packages/api/src/routes/posts.ts
+++ b/packages/api/src/routes/posts.ts
@@ -9,7 +9,7 @@ import { firePluginEvent } from "../services/webhooks.js";
import { decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
import { shouldCount } from "../lib/view-tracker.js";
-import { notifyBoardSubscribers } from "../services/push.js";
+import { notifyBoardSubscribers, notifyGlobalSubscribers } from "../services/push.js";
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
@@ -556,6 +556,12 @@ export default async function postRoutes(app: FastifyInstance) {
url: `/b/${board.slug}/post/${post.id}`,
tag: `board-${board.id}-new`,
});
+ notifyGlobalSubscribers({
+ title: `New post in ${board.name}`,
+ body: cleanTitle.slice(0, 100),
+ url: `/b/${board.slug}/post/${post.id}`,
+ tag: `global-new-post`,
+ });
reply.status(201).send({
id: post.id, type: post.type, title: post.title, description: post.description,
diff --git a/packages/api/src/routes/push.ts b/packages/api/src/routes/push.ts
index ccd58b3..b95e7e1 100644
--- a/packages/api/src/routes/push.ts
+++ b/packages/api/src/routes/push.ts
@@ -139,4 +139,60 @@ export default async function pushRoutes(app: FastifyInstance) {
reply.send(subs);
}
);
+
+ // global subscription status
+ app.get(
+ "/push/global-subscription",
+ { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
+ async (req, reply) => {
+ const sub = await prisma.pushSubscription.findFirst({
+ where: { userId: req.user!.id, boardId: null, postId: null },
+ select: { id: true },
+ });
+ reply.send({ subscribed: !!sub });
+ }
+ );
+
+ // global subscribe
+ app.post(
+ "/push/global-subscribe",
+ { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
+ async (req, reply) => {
+ const body = subscribeBody.parse(req.body);
+ const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
+
+ const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
+ if (existing) {
+ await prisma.pushSubscription.update({
+ where: { endpointIdx },
+ data: { userId: req.user!.id, boardId: null, postId: null },
+ });
+ } else {
+ await prisma.pushSubscription.create({
+ data: {
+ endpoint: encrypt(body.endpoint, masterKey),
+ endpointIdx,
+ keysP256dh: encrypt(body.keys.p256dh, masterKey),
+ keysAuth: encrypt(body.keys.auth, masterKey),
+ userId: req.user!.id,
+ boardId: null,
+ postId: null,
+ },
+ });
+ }
+ reply.send({ subscribed: true });
+ }
+ );
+
+ // global unsubscribe
+ app.delete(
+ "/push/global-subscribe",
+ { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
+ async (req, reply) => {
+ await prisma.pushSubscription.deleteMany({
+ where: { userId: req.user!.id, boardId: null, postId: null },
+ });
+ reply.send({ subscribed: false });
+ }
+ );
}
diff --git a/packages/api/src/services/push.ts b/packages/api/src/services/push.ts
index 4a8e447..9f51785 100644
--- a/packages/api/src/services/push.ts
+++ b/packages/api/src/services/push.ts
@@ -82,6 +82,11 @@ export async function notifyBoardSubscribers(boardId: string, event: PushPayload
await processResults(subs, event);
}
+export async function notifyGlobalSubscribers(event: PushPayload) {
+ const subs = await prisma.pushSubscription.findMany({ where: { boardId: null, postId: null } });
+ await processResults(subs, event);
+}
+
export async function notifyUserReply(userId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
await processResults(subs, event);
diff --git a/packages/web/src/pages/PostDetail.tsx b/packages/web/src/pages/PostDetail.tsx
index 56e6550..fed2e2c 100644
--- a/packages/web/src/pages/PostDetail.tsx
+++ b/packages/web/src/pages/PostDetail.tsx
@@ -8,7 +8,8 @@ import StatusBadge from '../components/StatusBadge'
import Timeline from '../components/Timeline'
import type { TimelineEntry } from '../components/Timeline'
import PluginSlot from '../components/PluginSlot'
-import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage } from '@tabler/icons-react'
+import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck } from '@tabler/icons-react'
+import Dropdown from '../components/Dropdown'
import EditHistoryModal from '../components/EditHistoryModal'
import Avatar from '../components/Avatar'
import Markdown from '../components/Markdown'
@@ -202,6 +203,30 @@ export default function PostDetail() {
const [historyLoading, setHistoryLoading] = useState(false)
const [showThreadLockDialog, setShowThreadLockDialog] = useState(false)
const [threadLockVoting, setThreadLockVoting] = useState(false)
+ const [boardStatuses, setBoardStatuses] = useState<{ status: string; label: string }[]>([])
+ const [statusChanging, setStatusChanging] = useState(false)
+
+ useEffect(() => {
+ if (!boardSlug) return
+ api.get<{ statuses: { status: string; label: string }[] }>(`/boards/${boardSlug}/statuses`)
+ .then((r) => setBoardStatuses(r.statuses))
+ .catch(() => {})
+ }, [boardSlug])
+
+ const changeStatus = async (newStatus: string) => {
+ if (!postId || !post || newStatus === post.status) return
+ setStatusChanging(true)
+ try {
+ await api.put(`/admin/posts/${postId}/status`, { status: newStatus })
+ setPost({ ...post, status: newStatus })
+ fetchPost()
+ toast.success('Status updated')
+ } catch {
+ toast.error('Failed to update status')
+ } finally {
+ setStatusChanging(false)
+ }
+ }
const fetchPost = async () => {
if (!boardSlug || !postId) return
@@ -523,7 +548,18 @@ export default function PostDetail() {
{typeLabel}
{post.isPinned &&